From 27ccb6ea9aaae4f67a01a9e1dd8dd9201cb9b847 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Tue, 25 Aug 2026 14:36:44 +0900 Subject: [PATCH] fix: enforce explicit platoon import ownership --- CHANGELOG.md | 13 +- README.md | 10 +- README_KR.md | 10 +- SECURITY.md | 17 +- .../PlatoonBackupManagerIntegrationTest.kt | 117 +++++-- .../PlatoonDatabaseIntegrationTest.kt | 15 +- .../PlatoonProfileRegistryIntegrationTest.kt | 85 ++++- .../gf2log/app/ActivePlatoonScopeBinding.kt | 5 +- .../main/java/dev/gf2log/app/MainActivity.kt | 273 ++++++++++++--- .../java/dev/gf2log/app/OptionsActivity.kt | 59 ++-- .../dev/gf2log/app/PacketHistoryActivity.kt | 14 +- .../java/dev/gf2log/app/PlatoonActivity.kt | 7 +- .../dev/gf2log/app/PlatoonCsvImportPrompt.kt | 313 ++++++++++++++++++ .../dev/gf2log/app/PlatoonProfileSelector.kt | 38 +-- .../java/dev/gf2log/app/PrimaryNavigation.kt | 21 +- .../dev/gf2log/app/WeeklyReportActivity.kt | 9 +- .../gf2log/app/capture/CaptureVpnService.kt | 21 +- .../app/management/PlatoonBackupManager.kt | 90 +++-- .../gf2log/app/management/PlatoonDatabase.kt | 8 +- .../gf2log/app/management/PlatoonProfile.kt | 130 +++++--- .../app/management/PlatoonRepository.kt | 4 +- app/src/main/res/drawable/ic_add_circle.xml | 13 + app/src/main/res/values-ko/strings.xml | 13 +- app/src/main/res/values/strings.xml | 13 +- docs/ARCHITECTURE.md | 46 ++- .../protocol/ParsedPayloadTextFormatter.kt | 1 + 26 files changed, 1069 insertions(+), 276 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt create mode 100644 app/src/main/res/drawable/ic_add_circle.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 486213a..2b96fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,17 @@ All notable changes to mobileGF2logger are documented here. 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 - immutable private databases and retained-evidence directories. +- Leave unscoped v2.3.x files untouched but remove them from selectable + production profiles; captured packets require a confirmed `21905` identity + before they can create or enter an immutable data scope. +- Require roster CSV imports to target an explicitly selected existing + profile or a newly user-declared client/server/name/ID profile, name that + destination in the preview, and explain that `21917` cannot identify or + verify a Platoon by itself. +- Scope recent and saved parsed-packet history to the admitted profile and + keep unconfirmed flow payloads out of history as well as management storage. +- Allow a scoped v3 backup to recreate its embedded Platoon profile on an empty + installation, while continuing to reject identity-free legacy archives. - Replace the arbitrary timezone list with the six supported server presets; an unconfigured client must be selected once before its first profile is admitted. diff --git a/README.md b/README.md index 76a2e94..5138638 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ never stored. - 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. - Recovers interrupted imports before previewing roster CSV impact and keeps an automatic one-level checkpoint for undo. +- Imports a roster CSV only into an explicitly selected, already confirmed + Platoon or a profile whose client, server, name, and ID the user explicitly + enters. Because `21917` contains no Platoon identity, the preview always + names the target and asks the user to verify it. - 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. @@ -59,7 +63,11 @@ 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. +The app keeps each confirmed Platoon's parsed history, management data, and +generated CSV files in separate 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 f030273..b288d15 100644 --- a/README_KR.md +++ b/README_KR.md @@ -20,6 +20,10 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android - 모든 주간 셀을 누르면 근거를 설명하고, 근거 상태 패널에서 누락되거나 불확실한 데이터를 요약합니다. - 주간 표마다 표시 당시의 멤버 문맥까지 포함한 전체 자동 기록을 최대 15개 보관하여 잘못 가져온 뒤에도 이전 표를 미리 보고 복원할 수 있습니다. - 중단된 가져오기를 먼저 복구한 뒤 명단 CSV의 영향을 미리 보여 주고, 실행 취소용 1단계 자동 체크포인트를 보관합니다. +- 명단 CSV는 사용자가 명시적으로 선택한 서클이나 클라이언트·서버·이름· + ID를 직접 입력한 신규 서클에만 가져옵니다. `21917`에는 서클 식별 + 정보가 없으므로 미리보기에는 항상 적용 대상을 표시하고 확인을 + 요청합니다. - 이름, UID, 비공개 메모의 포함 여부를 선택해 주간 PNG를 저장하거나 공유할 수 있습니다. - 확인 후 검증된 원본 CSV를 사용자가 소유한 선택적 Discord 수신 웹훅으로 전송할 수 있습니다. - 최근 파싱 패킷 100개와 저장 패킷 50개를 보관하며, 표 및 원본 보기, 복사, 내보내기, 선택, 삭제를 지원합니다. @@ -50,7 +54,11 @@ Android 10 이상에서는 Android가 각 연결을 소유한 지원 게임을 있는 서버 식별자가 없으므로 첫 캡처 전에 설정에서 HaoPlay와 Darkwinter의 서버를 올바르게 선택하세요. -앱은 파싱 내역, 관리 데이터, 생성한 CSV 파일을 기기의 비공개 저장소에 보관합니다. TLS, 인증서 고정 또는 안티치트 체계를 우회하지 않으며 게임 트래픽을 변경하지 않습니다. 서버 응답에는 최근의 일부 이력만 포함될 수 있으므로, 누락된 과거 가입 이력은 직접 추가할 수 있습니다. +앱은 확정된 각 서클의 파싱 내역, 관리 데이터, 생성한 CSV 파일을 서로 +분리된 기기 비공개 저장소에 보관합니다. TLS, 인증서 고정 또는 안티치트 +체계를 우회하지 않으며 게임 트래픽을 변경하지 않습니다. 서버 응답에는 +최근의 일부 이력만 포함될 수 있으므로, 누락된 과거 가입 이력은 직접 +추가할 수 있습니다. ## 참고 diff --git a/SECURITY.md b/SECURITY.md index 3f858eb..200a81d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -49,10 +49,12 @@ 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 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. +`21905` supplies a bounded Platoon identity for its own decoded flow. Captured +packets may enter an immutable isolated management scope only after that profile +combines the Android-verified client, user-selected compatible server region, +and Platoon ID. An identity-free `21917` document can enter a scope only through +the explicit user-driven CSV flow, where the preview names either an existing +profile or a newly declared client/server/name/ID destination before mutation. 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. @@ -69,6 +71,13 @@ 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. +Roster CSV payload `21917` has no Platoon identity. Import never infers a target +from member overlap. It requires an explicitly selected existing profile or a +new profile whose client, compatible server, name, and ID the user declares, +displays that destination before mutation, and aborts if the scope is no longer +available. This prevents an identity-free roster from silently crossing Platoon +storage boundaries. + 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. 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 82b355f..5252a9c 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -17,6 +17,7 @@ import dev.gf2log.app.settings.AppSettingsStore import dev.gf2log.app.settings.BackupSettingsStore import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.ScopedAppSettingsStore import dev.gf2log.app.settings.WeeklyCutlines import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.PayloadCatalog @@ -41,13 +42,26 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class PlatoonBackupManagerIntegrationTest { private lateinit var context: Context - private lateinit var settingsStore: AppSettingsStore + private lateinit var settingsStore: BackupSettingsStore + private lateinit var storageScope: PlatoonStorageScope @Before fun setUp() { context = ApplicationProvider.getApplicationContext() clearState() - settingsStore = AppSettingsStore(context) + val profile = PlatoonProfileRegistry(context).upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + dev.gf2log.protocol.model.PlatoonProfileData( + 101817u, + "Backup test Platoon", + emptyList(), + emptyList(), + ), + ) + check(PlatoonProfileRegistry(context).setActive(profile.storageId)) + storageScope = PlatoonStorageScope(profile.storageId) + settingsStore = ScopedAppSettingsStore(context, storageScope.storageId) } @After @@ -93,7 +107,7 @@ class PlatoonBackupManagerIntegrationTest { fun schemaTenCompleteBackupMigratesBeforeCurrentContractValidation() { seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") settingsStore.replace(archivedSettings()) - val databaseFile = context.getDatabasePath(PlatoonSchema.DATABASE_NAME) + val databaseFile = context.getDatabasePath(storageScope.databaseName) SQLiteDatabase.openDatabase(databaseFile.path, null, SQLiteDatabase.OPEN_READWRITE).use { legacy -> legacy.execSQL("DROP INDEX platoon_activity_resolution_retention") legacy.execSQL("DROP INDEX platoon_activity_retention_order") @@ -105,6 +119,7 @@ class PlatoonBackupManagerIntegrationTest { output, databaseFile, AppBackupSettingsCodec.encode(archivedSettings()), + PlatoonProfileRegistry(context).active(), ) }.toByteArray() @@ -286,8 +301,8 @@ class PlatoonBackupManagerIntegrationTest { PlatoonBackupManager(context).exportFull(it) }.toByteArray() - PlatoonRepository.withExclusiveDatabase { - assertTrue(context.deleteDatabase(PlatoonSchema.DATABASE_NAME)) + PlatoonRepository.withExclusiveDatabase(storageScope) { + assertTrue(context.deleteDatabase(storageScope.databaseName)) } settingsStore.replace(currentSettings()) @@ -304,7 +319,7 @@ class PlatoonBackupManagerIntegrationTest { } assertEquals(currentSettings(), settingsStore.read()) - assertFalse(context.getDatabasePath(PlatoonSchema.DATABASE_NAME).exists()) + assertFalse(context.getDatabasePath(storageScope.databaseName).exists()) assertFalse(FilePaths.preRestoreDatabase(context).exists()) assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) } @@ -436,12 +451,12 @@ class PlatoonBackupManagerIntegrationTest { @Test fun failedCsvImportPreservesThePreviousSuccessfulUndoCheckpoint() { seedDatabase(ARCHIVED_UID, "Before first import", "before-first-import.csv") - val firstImport = CsvImportCheckpointManager(context) + val firstImport = CsvImportCheckpointManager(context, storageScope) firstImport.create(emptySet()) replaceDatabaseForCheckpoint(CURRENT_UID, "After first import", "after-first-import.csv") firstImport.seal() - val failedImport = CsvImportCheckpointManager(context) + val failedImport = CsvImportCheckpointManager(context, storageScope) failedImport.create(emptySet()) replaceDatabaseForCheckpoint(THIRD_UID, "Failed second import", "failed-import.csv") failedImport.rollbackFailedImport() @@ -452,7 +467,7 @@ class PlatoonBackupManagerIntegrationTest { assertEquals(0L, count(db, "members", "uid = ?", THIRD_UID)) } - val previousUndo = CsvImportCheckpointManager(context) + val previousUndo = CsvImportCheckpointManager(context, storageScope) assertTrue(previousUndo.canUndo()) previousUndo.restore() @@ -461,13 +476,13 @@ class PlatoonBackupManagerIntegrationTest { assertEquals(1L, count(db, "members", "uid = ?", ARCHIVED_UID)) assertEquals(0L, count(db, "members", "uid = ?", CURRENT_UID)) } - assertFalse(CsvImportCheckpointManager(context).canUndo()) + assertFalse(CsvImportCheckpointManager(context, storageScope).canUndo()) } @Test fun interruptedCsvUndoCompletesAfterDatabaseInstallationOnRestart() { seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") - val manager = CsvImportCheckpointManager(context) { checkpoint -> + val manager = CsvImportCheckpointManager(context, storageScope) { checkpoint -> if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { throw SimulatedProcessDeath() } @@ -480,7 +495,7 @@ class PlatoonBackupManagerIntegrationTest { manager.restore() } - val recovered = CsvImportCheckpointManager(context) + val recovered = CsvImportCheckpointManager(context, storageScope) assertFalse(recovered.canUndo()) PlatoonDatabase(context).use { database -> @@ -509,7 +524,7 @@ class PlatoonBackupManagerIntegrationTest { ) replaceDatabaseForCheckpoint(CURRENT_UID, "Interrupted import", plannedFileName) val retained = java.io.File( - context.filesDir, + storageScope.rootDirectory(context), PlatoonRepository.RETAINED_CSV_DIRECTORY, ).apply { mkdirs() } java.io.File(retained, plannedFileName).writeText( @@ -517,7 +532,7 @@ class PlatoonBackupManagerIntegrationTest { Charsets.UTF_8, ) - CsvImportCheckpointManager(context) + CsvImportCheckpointManager(context, storageScope) val members = PlatoonRepository(context).listMemberStatuses() assertTrue(members.any { it.uid == ARCHIVED_UID }) @@ -635,8 +650,8 @@ class PlatoonBackupManagerIntegrationTest { } private fun replaceDatabaseForCheckpoint(uid: Long, name: String, sourceFile: String) { - PlatoonRepository.withExclusiveDatabase { - context.deleteDatabase(PlatoonSchema.DATABASE_NAME) + PlatoonRepository.withExclusiveDatabase(storageScope) { + context.deleteDatabase(storageScope.databaseName) } seedDatabase(uid, name, sourceFile) } @@ -835,6 +850,17 @@ class PlatoonBackupManagerIntegrationTest { } } } + java.io.File(context.cacheDir, "platoon-restore").deleteRecursively() + java.io.File(context.filesDir, "platoon-full-restore").deleteRecursively() + java.io.File(context.filesDir, PlatoonRepository.RETAINED_CSV_DIRECTORY).deleteRecursively() + java.io.File( + context.filesDir, + "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", + ).deleteRecursively() + java.io.File(context.filesDir, "csv-import-checkpoint").deleteRecursively() + java.io.File(context.filesDir, "csv-import-checkpoint.staging").deleteRecursively() + java.io.File(context.filesDir, "csv-import-checkpoint.previous").deleteRecursively() + java.io.File(context.filesDir, "platoons").deleteRecursively() context.getSharedPreferences(USER_SETTINGS, Context.MODE_PRIVATE).edit().clear().commit() listOf( "platoon_profiles", @@ -844,21 +870,18 @@ class PlatoonBackupManagerIntegrationTest { ).forEach { preferences -> context.getSharedPreferences(preferences, Context.MODE_PRIVATE).edit().clear().commit() } - FilePaths.restoreDirectory(context).deleteRecursively() - FilePaths.restoreTransactionDirectory(context).deleteRecursively() - FilePaths.retainedCsvDirectory(context).deleteRecursively() - FilePaths.previousRetainedCsvDirectory(context).deleteRecursively() - FilePaths.csvCheckpointDirectory(context).deleteRecursively() - FilePaths.csvCheckpointStagingDirectory(context).deleteRecursively() - FilePaths.csvCheckpointPreviousDirectory(context).deleteRecursively() - java.io.File(context.filesDir, "platoons").deleteRecursively() } private object FilePaths { - fun restoreDirectory(context: Context) = java.io.File(context.cacheDir, "platoon-restore") + private fun scope(context: Context) = PlatoonProfileRegistry(context).activeScope() + + fun restoreDirectory(context: Context) = java.io.File( + context.cacheDir, + "platoon-restore/${scope(context).storageId}", + ) fun restoreTransactionDirectory(context: Context) = - java.io.File(context.filesDir, "platoon-full-restore") + java.io.File(scope(context).rootDirectory(context), "platoon-full-restore") fun restoreSettingsFile(context: Context) = java.io.File(restoreTransactionDirectory(context), "settings.pre_restore") @@ -872,28 +895,28 @@ class PlatoonBackupManagerIntegrationTest { .filter { it.name.startsWith("guild-members.retired-") } fun retainedCsvDirectory(context: Context) = java.io.File( - context.filesDir, + scope(context).rootDirectory(context), PlatoonRepository.RETAINED_CSV_DIRECTORY, ) fun previousRetainedCsvDirectory(context: Context) = java.io.File( - context.filesDir, + scope(context).rootDirectory(context), "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", ) fun preRestoreDatabase(context: Context) = java.io.File( - context.getDatabasePath(PlatoonSchema.DATABASE_NAME).parentFile, - "${PlatoonSchema.DATABASE_NAME}.pre_restore", + context.getDatabasePath(scope(context).databaseName).parentFile, + "${scope(context).databaseName}.pre_restore", ) fun csvCheckpointDirectory(context: Context) = - java.io.File(context.filesDir, "csv-import-checkpoint") + java.io.File(scope(context).rootDirectory(context), "csv-import-checkpoint") fun csvCheckpointStagingDirectory(context: Context) = - java.io.File(context.filesDir, "csv-import-checkpoint.staging") + java.io.File(scope(context).rootDirectory(context), "csv-import-checkpoint.staging") fun csvCheckpointPreviousDirectory(context: Context) = - java.io.File(context.filesDir, "csv-import-checkpoint.previous") + java.io.File(scope(context).rootDirectory(context), "csv-import-checkpoint.previous") } private class SimulatedProcessDeath : Error() @@ -910,9 +933,34 @@ class PlatoonBackupManagerIntegrationTest { } @RunWith(AndroidJUnit4::class) class WeeklyReportActivityStateTest { + private val context = ApplicationProvider.getApplicationContext() + + @Before + fun setUpProfile() { + context.getSharedPreferences("platoon_profiles", Context.MODE_PRIVATE) + .edit().clear().commit() + val profile = PlatoonProfileRegistry(context).upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + dev.gf2log.protocol.model.PlatoonProfileData( + 101817u, + "Weekly UI test", + emptyList(), + emptyList(), + ), + ) + check(PlatoonProfileRegistry(context).setActive(profile.storageId)) + } + + @After + fun clearProfile() { + context.getSharedPreferences("platoon_profiles", Context.MODE_PRIVATE) + .edit().clear().commit() + java.io.File(context.filesDir, "platoons").deleteRecursively() + } + @Test fun repeatedWeeklyPngRendersUseDifferentProviderUris() { - val context = ApplicationProvider.getApplicationContext() val periodStart = LocalDate.of(2026, 8, 9) val document = WeeklyShareProjection.Document( title = "GF2logger", @@ -970,7 +1018,6 @@ class WeeklyReportActivityStateTest { @Test fun pendingWeeklyPngSurvivesActivityRecreation() { - val context = ApplicationProvider.getApplicationContext() WeeklyPngPendingState.directory(context.cacheDir).mkdirs() val target = WeeklyPngPendingState .newRenderTarget(context.cacheDir, LocalDate.of(2026, 8, 9)) 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 32e207f..4942a13 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -25,7 +25,7 @@ class PlatoonDatabaseIntegrationTest { fun setUp() { context = ApplicationProvider.getApplicationContext() context.deleteDatabase(TEST_DATABASE) - database = PlatoonDatabase(context, TEST_DATABASE) + database = PlatoonDatabase(context, TEST_DATABASE, TEST_SCOPE) } @After @@ -487,7 +487,7 @@ class PlatoonDatabaseIntegrationTest { context.deleteDatabase(databaseName) try { createLegacyActivityDatabase(databaseName, legacyVersion) - PlatoonDatabase(context, databaseName).use { upgraded -> + PlatoonDatabase(context, databaseName, TEST_SCOPE).use { upgraded -> val writable = upgraded.writableDatabase assertEquals(PlatoonSchema.CURRENT_VERSION, writable.version) assertEquals( @@ -526,7 +526,7 @@ class PlatoonDatabaseIntegrationTest { val databaseName = "platoon-v10-upgrade-test.db" context.deleteDatabase(databaseName) try { - PlatoonDatabase(context, databaseName).use { helper -> helper.writableDatabase } + PlatoonDatabase(context, databaseName, TEST_SCOPE).use { helper -> helper.writableDatabase } context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null).use { legacy -> legacy.execSQL("DROP INDEX platoon_activity_resolution_retention") legacy.execSQL("DROP INDEX platoon_activity_retention_order") @@ -534,7 +534,7 @@ class PlatoonDatabaseIntegrationTest { legacy.version = 10 } - PlatoonDatabase(context, databaseName).use { upgraded -> + PlatoonDatabase(context, databaseName, TEST_SCOPE).use { upgraded -> val writable = upgraded.writableDatabase assertEquals(PlatoonSchema.CURRENT_VERSION, writable.version) assertEquals( @@ -573,7 +573,7 @@ class PlatoonDatabaseIntegrationTest { val databaseName = "platoon-v12-membership-state-test.db" context.deleteDatabase(databaseName) try { - PlatoonDatabase(context, databaseName).use { helper -> + PlatoonDatabase(context, databaseName, TEST_SCOPE).use { helper -> val writable = helper.writableDatabase writable.execSQL( "INSERT INTO members(" + @@ -597,7 +597,7 @@ class PlatoonDatabaseIntegrationTest { legacy.version = 12 } - PlatoonDatabase(context, databaseName).use { upgraded -> + PlatoonDatabase(context, databaseName, TEST_SCOPE).use { upgraded -> assertEquals(PlatoonSchema.CURRENT_VERSION, upgraded.writableDatabase.version) assertTrue(upgraded.listMemberStatuses().single().isActive) } @@ -774,7 +774,7 @@ class PlatoonDatabaseIntegrationTest { } database.close() - database = PlatoonDatabase(context, TEST_DATABASE) + database = PlatoonDatabase(context, TEST_DATABASE, TEST_SCOPE) database.writableDatabase assertEquals( @@ -1252,6 +1252,7 @@ class PlatoonDatabaseIntegrationTest { private companion object { const val TEST_DATABASE = "platoon-integration-test.db" + val TEST_SCOPE = PlatoonStorageScope("0".repeat(32)) const val TARGET_UID = 1001L const val OTHER_UID = 2002L } 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 ed38e6e..6123bcf 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt @@ -20,6 +20,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before @@ -36,7 +37,7 @@ class PlatoonProfileRegistryIntegrationTest { @Test fun emptyDatabaseIsNotRegisteredAsLegacyData() { - PlatoonDatabase(context).use { database -> + PlatoonDatabase(context, PlatoonSchema.DATABASE_NAME).use { database -> database.recordWeeklyReportHistory( periodStartEpochDay = 0L, recordedAt = Instant.EPOCH, @@ -49,26 +50,55 @@ class PlatoonProfileRegistryIntegrationTest { val registry = PlatoonProfileRegistry(context) assertTrue(registry.ensureInitialized().isEmpty()) - assertEquals(PlatoonProfileIdentity.LEGACY_STORAGE_ID, registry.activeScope().storageId) + assertNull(registry.active()) + assertThrows(IllegalArgumentException::class.java) { registry.activeScope() } } @Test - fun existingDatabaseWithManagementDataIsRegisteredWithoutMovingIt() { + fun existingUnscopedDatabaseIsNeverRegisteredAsAProfile() { val legacyDatabase = context.getDatabasePath(PlatoonSchema.DATABASE_NAME) - PlatoonRepository(context).ingest( + PlatoonRepository( + context, + PlatoonStorageScope(PlatoonProfileIdentity.LEGACY_STORAGE_ID), + ).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(registry.ensureInitialized().isEmpty()) + assertNull(registry.active()) assertTrue(legacyDatabase.isFile) } + @Test + fun staleLegacySelectorMetadataIsRetiredInsteadOfRecreated() { + context.getSharedPreferences("platoon_profiles", android.content.Context.MODE_PRIVATE) + .edit() + .putStringSet("profile_ids", setOf(PlatoonProfileIdentity.LEGACY_STORAGE_ID)) + .putString("active_profile", PlatoonProfileIdentity.LEGACY_STORAGE_ID) + .putString("profile.legacy.client", PlatoonClient.LEGACY.name) + .putString("profile.legacy.region", GameServerRegion.MANUAL.storedValue) + .putLong("profile.legacy.platoon_id", 0L) + .putString("profile.legacy.name", "Existing platoon data") + .putLong("profile.legacy.last_seen", 0L) + .putBoolean("profile.legacy.legacy", true) + .commit() + + val registry = PlatoonProfileRegistry(context) + + assertTrue(registry.list().isEmpty()) + assertNull(registry.active()) + val stored = context.getSharedPreferences( + "platoon_profiles", + android.content.Context.MODE_PRIVATE, + ) + assertTrue(stored.getStringSet("profile_ids", emptySet()).orEmpty().isEmpty()) + assertNull(stored.getString("active_profile", null)) + } + @Test fun publisherAndRegionKeepEqualPlatoonIdsInDifferentScopes() { val registry = PlatoonProfileRegistry(context) @@ -98,6 +128,41 @@ class PlatoonProfileRegistryIntegrationTest { assertFalse(registry.setActive("0".repeat(32))) } + @Test + fun declaredCsvDestinationIsIsolatedAndLaterMatchingCaptureReusesItsScope() { + val registry = PlatoonProfileRegistry(context) + val declared = registry.createDeclared( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + "Declared Owls", + Instant.parse("2026-08-25T00:00:00Z"), + ) + + assertTrue(registry.setActive(declared.storageId)) + assertEquals(declared.storageId, registry.activeScope().storageId) + assertThrows(IllegalArgumentException::class.java) { + registry.createDeclared( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + "Duplicate", + ) + } + + val captured = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(101817u, "Captured Owls", listOf(7u), listOf(8u)), + Instant.parse("2026-08-25T01:00:00Z"), + ) + + assertEquals(declared.storageId, captured.storageId) + assertEquals("Captured Owls", captured.platoonName) + assertEquals(listOf(7L), captured.emblemPrimary) + assertEquals(listOf(8L), captured.emblemSecondary) + } + @Test fun clientRegionRequiresARealSelectionAndBindingsDetectProfileChanges() { val regions = ClientServerRegionPreferences(context) @@ -394,7 +459,11 @@ class PlatoonProfileRegistryIntegrationTest { .edit().clear().commit() File(context.filesDir, "platoons").deleteRecursively() - PlatoonBackupManager(context).restoreFull(ByteArrayInputStream(archive)) + PlatoonBackupManager.restoreSelected( + context, + ByteArrayInputStream(archive), + complete = true, + ) val restoredRegistry = PlatoonProfileRegistry(context) assertEquals(profile.storageId, restoredRegistry.activeScope().storageId) diff --git a/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt b/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt index ce67a56..697f0b5 100644 --- a/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt +++ b/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt @@ -6,8 +6,9 @@ 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() + val scope: PlatoonStorageScope? = PlatoonProfileRegistry(context).active() + ?.let { PlatoonStorageScope(it.storageId) } fun isCurrent(context: Context): Boolean = - PlatoonProfileRegistry(context).activeScope().storageId == scope.storageId + PlatoonProfileRegistry(context).active()?.storageId == scope?.storageId } diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 63fdbb1..7e34748 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -36,10 +36,12 @@ import dev.gf2log.app.management.PlatoonBackupManager import dev.gf2log.app.management.CsvImportCheckpointManager import dev.gf2log.app.management.CsvImportPreviewAnalyzer import dev.gf2log.app.management.PlatoonCsvImportStore +import dev.gf2log.app.management.PlatoonProfile import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.management.BackupFileName +import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.Gfl2PayloadDecoder import dev.gf2log.protocol.PayloadCatalog @@ -79,23 +81,35 @@ class MainActivity : LocalizedActivity() { private var pendingExport: File? = null private var captureOnceRequested = false private var pendingCsvImport: PendingCsvImport? = null + private var pendingCsvPickerStorageId: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + pendingCsvPickerStorageId = savedInstanceState?.getString(STATE_CSV_TARGET) profileBinding = ActivePlatoonScopeBinding(this) if (!OnboardingPreferences.isCompleted(this)) { startActivity(Intent(this, OnboardingActivity::class.java)) finish() return } - historyStore = CaptureHistoryStore( - File(filesDir, CaptureHistoryStore.HISTORY_DIRECTORY), - ) - savedHistoryStore = SavedHistoryStore( - File(filesDir, SavedHistoryStore.SAVED_HISTORY_DIRECTORY), - ) + val historyRoot = profileBinding.scope?.rootDirectory(this) + ?: File(cacheDir, "no-profile-history") + historyStore = CaptureHistoryStore(File(historyRoot, CaptureHistoryStore.HISTORY_DIRECTORY)) + savedHistoryStore = SavedHistoryStore(File(historyRoot, SavedHistoryStore.SAVED_HISTORY_DIRECTORY)) setContentView(buildContentView()) requestNotificationPermissionIfNeeded() + intent.getStringExtra(EXTRA_LAUNCH_CSV_PICKER) + ?.takeIf { it == profileBinding.scope?.storageId } + ?.let { storageId -> + intent.removeExtra(EXTRA_LAUNCH_CSV_PICKER) + pendingCsvPickerStorageId = storageId + window.decorView.post(::selectPlatoonCsvFiles) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + pendingCsvPickerStorageId?.let { outState.putString(STATE_CSV_TARGET, it) } + super.onSaveInstanceState(outState) } override fun onResume() { @@ -150,6 +164,7 @@ class MainActivity : LocalizedActivity() { requestCode == REQUEST_BACKUP_EXPORT -> { val destination = data?.data if (resultCode != RESULT_OK || destination == null) return + val scope = profileBinding.scope ?: return runFileOperation( successMessage = { getString(R.string.status_backup_exported) }, failureMessage = { getString(R.string.status_backup_failed) }, @@ -158,7 +173,7 @@ class MainActivity : LocalizedActivity() { contentResolver, destination, ) ?: error("Document provider did not open an output stream") - output.use { PlatoonBackupManager(this).export(it) } + output.use { PlatoonBackupManager(this, scope).export(it) } } } requestCode == REQUEST_BACKUP_IMPORT -> { @@ -171,21 +186,35 @@ class MainActivity : LocalizedActivity() { runFileOperation( successMessage = { getString(R.string.status_backup_restored) }, failureMessage = { getString(R.string.status_backup_failed) }, + onSuccess = { recreate() }, ) { val input = TrustedImportSource.openInputStream(contentResolver, source) ?: error("Document provider did not open an input stream") - input.use { PlatoonBackupManager(this).restore(it) } + input.use { PlatoonBackupManager.restoreSelected(this, it, complete = false) } } } requestCode == REQUEST_CSV_IMPORT -> { - if (resultCode != RESULT_OK) return + if (resultCode != RESULT_OK) { + pendingCsvPickerStorageId = null + return + } val sources = buildList { data?.clipData?.let { clip -> repeat(clip.itemCount) { index -> add(clip.getItemAt(index).uri) } } data?.data?.let(::add) }.distinct() - if (sources.isNotEmpty()) preparePlatoonCsvSources(sources) + if (sources.isNotEmpty()) { + val target = resolveCsvImportTarget(pendingCsvPickerStorageId) + if (target == null) { + pendingCsvPickerStorageId = null + statusText.text = getString(R.string.csv_import_target_changed) + } else { + preparePlatoonCsvSources(sources, target) + } + } else { + pendingCsvPickerStorageId = null + } } } } @@ -193,6 +222,7 @@ class MainActivity : LocalizedActivity() { private fun runFileOperation( successMessage: () -> String, failureMessage: () -> String, + onSuccess: () -> Unit = {}, operation: () -> Unit, ) { fileIoExecutor.execute { @@ -200,6 +230,7 @@ class MainActivity : LocalizedActivity() { statusHandler.post { if (!isFinishing && !isDestroyed) { statusText.text = if (succeeded) successMessage() else failureMessage() + if (succeeded) onSuccess() } } } @@ -244,18 +275,41 @@ class MainActivity : LocalizedActivity() { } addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(View(context).apply { - background = GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(getColor(R.color.accent)) + gravity = Gravity.TOP + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(View(context).apply { + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(getColor(R.color.accent)) + } + }, LinearLayout.LayoutParams(dp(8), dp(8)).apply { + marginEnd = dp(8) + }) + addView(TextView(context).apply { + text = getString(R.string.capture_status_label) + textSize = 13f + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + }) + }, matchWidth()) + captureStateText = TextView(context).apply { + textSize = 22f + setTextColor(getColor(R.color.success_text)) + setTypeface(typeface, Typeface.BOLD) + setPadding(0, dp(2), 0, 0) } - }, LinearLayout.LayoutParams(dp(8), dp(8)).apply { marginEnd = dp(8) }) - addView(TextView(context).apply { - text = getString(R.string.capture_status_label) - textSize = 13f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(captureStateText, matchWidth()) + captureStatusText = TextView(context).apply { + textSize = 13f + setTextColor(getColor(R.color.text_secondary)) + } + addView(captureStatusText, matchWidth()) + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginEnd = dp(8) + }) addView( PlatoonProfileSelector.controls(this@MainActivity, compact = true), LinearLayout.LayoutParams( @@ -264,26 +318,14 @@ class MainActivity : LocalizedActivity() { ), ) }, matchWidth()) - captureStateText = TextView(context).apply { - textSize = 22f - setTextColor(getColor(R.color.success_text)) - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(8), 0, 0) - } - addView(captureStateText, matchWidth()) - captureStatusText = TextView(context).apply { - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(1), 0, dp(8)) - } - addView(captureStatusText, matchWidth()) addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL + gravity = Gravity.TOP addView(TextView(context).apply { text = getString(R.string.capture_target) textSize = 12f setTextColor(getColor(R.color.text_secondary)) + setPadding(0, dp(4), 0, 0) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) addView(ImageButton(context).apply { setImageResource(R.drawable.ic_info_outline) @@ -359,13 +401,13 @@ class MainActivity : LocalizedActivity() { title = getString(R.string.platoon_management), detail = getString(R.string.platoon_shortcut_detail), icon = R.drawable.ic_group, - ) { startActivity(Intent(this@MainActivity, PlatoonActivity::class.java)) }, + ) { openScopedActivity(PlatoonActivity::class.java) }, LinearLayout.LayoutParams(0, dp(76), 1f).apply { marginEnd = dp(5) }) addView(featureShortcut( title = getString(R.string.weekly_table), detail = getString(R.string.weekly_shortcut_detail), icon = R.drawable.ic_calendar, - ) { startActivity(Intent(this@MainActivity, WeeklyReportActivity::class.java)) }, + ) { openScopedActivity(WeeklyReportActivity::class.java) }, LinearLayout.LayoutParams(0, dp(76), 1f).apply { marginStart = dp(5) }) }, LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -375,13 +417,13 @@ class MainActivity : LocalizedActivity() { addView(sectionLabel(getString(R.string.data_tools)), matchWidth()) listOf( ModernUi.listRow(context, getString(R.string.import_platoon_csv), icon = R.drawable.ic_edit) { - selectPlatoonCsvFiles() + showCsvImportSelector() }, ModernUi.listRow(context, getString(R.string.export_platoon_backup), icon = R.drawable.ic_save) { - exportPlatoonBackup() + if (requireActiveScope() != null) exportPlatoonBackup() }, ModernUi.listRow(context, getString(R.string.undo_last_csv_import), icon = R.drawable.ic_arrow_back) { - confirmUndoLastCsvImport() + if (requireActiveScope() != null) confirmUndoLastCsvImport() }, ModernUi.listRow(context, getString(R.string.import_platoon_backup), icon = R.drawable.ic_save) { confirmImportPlatoonBackup() @@ -389,7 +431,6 @@ class MainActivity : LocalizedActivity() { ).forEach { row -> addView(row, matchWidth()) } - addView(sectionLabel(getString(R.string.recent_packets, CaptureHistoryStore.MAX_ENTRIES)), matchWidth()) historyContainer = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL @@ -590,6 +631,62 @@ class MainActivity : LocalizedActivity() { } } + private fun requireActiveScope(): PlatoonStorageScope? = profileBinding.scope.also { scope -> + if (scope == null) { + showNoPlatoonMessage() + } + } + + private fun showNoPlatoonMessage() { + AlertDialog.Builder(this) + .setMessage(R.string.no_platoon_detected_detail) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + private fun showCsvImportSelector() { + if (CaptureStatus.isRunning) { + statusText.text = getString(R.string.stop_capture_before_csv_import) + return + } + PlatoonCsvImportPrompt.show(this, ::beginCsvImport) + } + + private fun beginCsvImport(profile: PlatoonProfile) { + val registry = PlatoonProfileRegistry(this) + if (!registry.setActive(profile.storageId)) { + statusText.text = getString(R.string.csv_import_target_changed) + return + } + pendingCsvPickerStorageId = profile.storageId + if (profileBinding.scope?.storageId == profile.storageId) { + selectPlatoonCsvFiles() + } else { + intent.putExtra(EXTRA_LAUNCH_CSV_PICKER, profile.storageId) + recreate() + } + } + + private fun resolveCsvImportTarget(storageId: String?): CsvImportTarget? { + val scope = storageId?.let(::PlatoonStorageScope) ?: return null + val registry = PlatoonProfileRegistry(this) + val activeProfile = registry.active() + if ( + activeProfile == null || + activeProfile.storageId != scope.storageId || + activeProfile.legacy || + profileBinding.scope != scope + ) { + statusText.text = getString(R.string.csv_import_target_changed) + return null + } + return CsvImportTarget(scope, activeProfile) + } + + private fun openScopedActivity(activityClass: Class) { + if (requireActiveScope() != null) startActivity(Intent(this, activityClass)) + } + @Suppress("DEPRECATION") private fun exportPlatoonBackup() { if (CaptureStatus.isRunning) { @@ -627,6 +724,10 @@ class MainActivity : LocalizedActivity() { // Returns: // - Unit after dispatching the picker activity. private fun selectPlatoonCsvFiles() { + if (resolveCsvImportTarget(pendingCsvPickerStorageId) == null) { + pendingCsvPickerStorageId = null + return + } val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("text/csv") @@ -645,12 +746,12 @@ class MainActivity : LocalizedActivity() { // - sources: Distinct document-provider URIs returned by the picker. // Returns: // - Unit after scheduling validation and preview display. - private fun preparePlatoonCsvSources(sources: List) { + private fun preparePlatoonCsvSources(sources: List, target: CsvImportTarget) { if (CaptureStatus.isRunning) { statusText.text = getString(R.string.stop_capture_before_csv_import) return } - val storageScope = PlatoonProfileRegistry(this).activeScope() + val storageScope = target.storageScope statusText.text = getString(R.string.csv_import_preparing_preview) fileIoExecutor.execute { val result = runCatching { @@ -691,7 +792,13 @@ class MainActivity : LocalizedActivity() { duplicateFiles = analyzed.duplicateFiles + selected.size - unique.size, totalBytes = selectedBytes, ) - PendingCsvImport(storageScope, unique, duplicateNames, preview) + PendingCsvImport( + storageScope = storageScope, + destinationProfile = target.profile, + prepared = unique, + duplicateFileNames = duplicateNames, + preview = preview, + ) } statusHandler.post { if (isFinishing || isDestroyed) return@post @@ -702,6 +809,7 @@ class MainActivity : LocalizedActivity() { }, onFailure = { pendingCsvImport = null + pendingCsvPickerStorageId = null statusText.text = getString(R.string.status_platoon_csv_import_failed) }, ) @@ -725,6 +833,12 @@ class MainActivity : LocalizedActivity() { .setTitle(R.string.csv_import_preview_title) .setMessage( getString( + R.string.csv_import_destination, + pending.destinationProfile.platoonName, + pending.destinationProfile.platoonId, + pending.destinationProfile.client.displayName, + csvImportRegionLabel(pending.destinationProfile.serverRegion), + ) + "\n\n" + getString( R.string.csv_import_preview_summary, preview.validatedFiles, preview.duplicateFiles, @@ -738,18 +852,57 @@ class MainActivity : LocalizedActivity() { range, ), ) - .setNegativeButton(android.R.string.cancel) { _, _ -> pendingCsvImport = null } + .setNegativeButton(android.R.string.cancel) { _, _ -> + pendingCsvImport = null + pendingCsvPickerStorageId = null + } .setPositiveButton(R.string.import_platoon_csv) { _, _ -> applyPreparedCsvImport(pending) } - .setOnCancelListener { pendingCsvImport = null } + .setOnCancelListener { + pendingCsvImport = null + pendingCsvPickerStorageId = null + } .show() } + private fun csvImportRegionLabel(region: GameServerRegion): String = getString( + when (region) { + GameServerRegion.MANUAL -> R.string.server_region_manual + GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global + GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china + GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global + GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan + GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea + GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia + }, + ) + private fun applyPreparedCsvImport(pending: PendingCsvImport) { - if (pendingCsvImport !== pending || CaptureStatus.isRunning) { + val registry = PlatoonProfileRegistry(this) + val targetStillExists = registry.find(pending.storageScope.storageId) + ?.let { + it.client == pending.destinationProfile.client && + it.platoonId == pending.destinationProfile.platoonId + } + ?: false + val targetStillSelected = registry.active()?.storageId == pending.storageScope.storageId + if ( + pendingCsvImport !== pending || + CaptureStatus.isRunning || + profileBinding.scope != pending.storageScope || + !targetStillExists || + !targetStillSelected + ) { pendingCsvImport = null - statusText.text = getString(R.string.stop_capture_before_csv_import) + pendingCsvPickerStorageId = null + statusText.text = getString( + if (CaptureStatus.isRunning) { + R.string.stop_capture_before_csv_import + } else { + R.string.csv_import_target_changed + }, + ) return } pendingCsvImport = null @@ -784,6 +937,7 @@ class MainActivity : LocalizedActivity() { } statusHandler.post { if (isFinishing || isDestroyed) return@post + pendingCsvPickerStorageId = null statusText.text = result.fold( onSuccess = { summary -> getString( @@ -805,7 +959,8 @@ class MainActivity : LocalizedActivity() { statusText.text = getString(R.string.stop_capture_before_csv_import) return } - if (!CsvImportCheckpointManager(this).canUndo()) { + val scope = requireActiveScope() ?: return + if (!CsvImportCheckpointManager(this, scope).canUndo()) { statusText.text = getString(R.string.no_csv_import_checkpoint) return } @@ -818,9 +973,10 @@ class MainActivity : LocalizedActivity() { } private fun undoLastCsvImport() { + val scope = requireActiveScope() ?: return statusText.text = getString(R.string.csv_import_undoing) fileIoExecutor.execute { - val succeeded = runCatching { CsvImportCheckpointManager(this).restore() }.isSuccess + val succeeded = runCatching { CsvImportCheckpointManager(this, scope).restore() }.isSuccess statusHandler.post { if (isFinishing || isDestroyed) return@post statusText.text = getString( @@ -853,8 +1009,7 @@ class MainActivity : LocalizedActivity() { @Suppress("DEPRECATION") private fun exportLatestPlatoonCsv() { - val directory = PlatoonProfileRegistry(this).activeScope() - .retainedCsvDirectory(this) + val directory = (requireActiveScope() ?: return).retainedCsvDirectory(this) val latest = PlatoonCsvImportStore.latestRetainedFile(directory) if (latest == null) { statusText.text = getString(R.string.status_no_platoon_csv) @@ -932,7 +1087,11 @@ class MainActivity : LocalizedActivity() { Intent(this@MainActivity, PacketHistoryActivity::class.java) .putExtra(PacketHistoryActivity.EXTRA_ENTRY_ID, entry.id) .putExtra(PacketHistoryActivity.EXTRA_ENTRY_TITLE, entry.title) - .putExtra(PacketHistoryActivity.EXTRA_SAVED_ENTRY, saved), + .putExtra(PacketHistoryActivity.EXTRA_SAVED_ENTRY, saved) + .putExtra( + PacketHistoryActivity.EXTRA_STORAGE_ID, + profileBinding.scope?.storageId, + ), ) } }, LinearLayout.LayoutParams(0, rowHeight, 1f)) @@ -1055,6 +1214,8 @@ class MainActivity : LocalizedActivity() { const val MAX_CSV_IMPORT_FILES = 64 const val MAX_CSV_IMPORT_BYTES = 16L * 1024 * 1024 const val STATUS_REFRESH_MILLIS = 1_000L + const val EXTRA_LAUNCH_CSV_PICKER = "launch_csv_picker_for_storage_id" + const val STATE_CSV_TARGET = "pending_csv_target" val BACKUP_TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") val BACKUP_MIME_TYPES = arrayOf( PlatoonBackupManager.MIME_TYPE, @@ -1071,8 +1232,14 @@ class MainActivity : LocalizedActivity() { private data class PendingCsvImport( val storageScope: PlatoonStorageScope, + val destinationProfile: PlatoonProfile, val prepared: List, val duplicateFileNames: Set, val preview: CsvImportPreviewAnalyzer.Preview, ) + + private data class CsvImportTarget( + val storageScope: PlatoonStorageScope, + val profile: PlatoonProfile, + ) } diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index b818d35..8df719e 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -77,13 +77,16 @@ class OptionsActivity : LocalizedActivity() { val uri = data?.data if (resultCode != Activity.RESULT_OK || uri == null) return when (requestCode) { - REQUEST_FULL_BACKUP_EXPORT -> runBackupOperation( - successMessage = R.string.full_backup_exported, - failureMessage = { R.string.full_backup_export_failed }, - ) { - val output = TrustedExportDestination.openOutputStream(contentResolver, uri) - ?: error("Document provider did not open an output stream") - output.use { PlatoonBackupManager(this).exportFull(it) } + REQUEST_FULL_BACKUP_EXPORT -> { + val scope = requireActiveScope() ?: return + runBackupOperation( + successMessage = R.string.full_backup_exported, + failureMessage = { R.string.full_backup_export_failed }, + ) { + val output = TrustedExportDestination.openOutputStream(contentResolver, uri) + ?: error("Document provider did not open an output stream") + output.use { PlatoonBackupManager(this, scope).exportFull(it) } + } } REQUEST_FULL_BACKUP_RESTORE -> { if (!hasBackupExtension(uri)) { @@ -96,7 +99,7 @@ class OptionsActivity : LocalizedActivity() { ) { val input = TrustedImportSource.openInputStream(contentResolver, uri) ?: error("Document provider did not open an input stream") - input.use { PlatoonBackupManager(this).restoreFull(it) } + input.use { PlatoonBackupManager.restoreSelected(this, it, complete = true) } } } } @@ -190,16 +193,30 @@ class OptionsActivity : LocalizedActivity() { setPadding(0, 0, 0, dp(6)) }, matchWidth()) val resetScope = profileBinding.scope - val resetRegion = GameTimeZonePreferences.region(context, resetScope.storageId) + val resetRegion = resetScope?.let { GameTimeZonePreferences.region(context, it.storageId) } addView(ModernUi.listRow( context = context, title = getString(R.string.server_region), - detail = resetSummary( - resetRegion, - GameTimeZonePreferences.isAutomatic(context, resetScope.storageId), - ), + detail = if (resetRegion == null) { + getString(R.string.no_platoon_detected) + } else { + resetSummary( + resetRegion, + GameTimeZonePreferences.isAutomatic(context, resetScope.storageId), + ) + }, icon = R.drawable.ic_calendar, - onClick = ::chooseGameServerRegion, + onClick = { + if (resetScope == null) { + Toast.makeText( + context, + R.string.no_platoon_detected_detail, + Toast.LENGTH_SHORT, + ).show() + } else { + chooseGameServerRegion() + } + }, ), matchWidth()) addView(ModernUi.listRow( context = context, @@ -441,6 +458,7 @@ class OptionsActivity : LocalizedActivity() { @Suppress("DEPRECATION") private fun exportFullBackup() { + if (requireActiveScope() == null) return if (CaptureStatus.isRunning) { showBackupMessage(R.string.stop_capture_before_backup) return @@ -490,6 +508,11 @@ class OptionsActivity : LocalizedActivity() { android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() } + private fun requireActiveScope(): dev.gf2log.app.management.PlatoonStorageScope? = + profileBinding.scope.also { scope -> + if (scope == null) showBackupMessage(R.string.no_platoon_detected_detail) + } + private fun changeLanguage(language: String) { if (LanguagePreferences.get(this) == language) return LanguagePreferences.set(this, language) @@ -517,13 +540,7 @@ class OptionsActivity : LocalizedActivity() { ) { dialog, which -> dialog.dismiss() if (which == 0) { - if (storageScope.isLegacy) { - Toast.makeText( - this, - R.string.auto_region_requires_detected_platoon, - Toast.LENGTH_SHORT, - ).show() - } else if (!automatic) { + if (!automatic) { updateGameTimeZone(storageScope, null) } } else { diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index a5e4a38..ddfc05c 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -28,6 +28,8 @@ import dev.gf2log.app.discord.OriginalCsvPayload import java.util.concurrent.Executors import dev.gf2log.protocol.ParsedPacketTableParser import java.io.File +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.management.PlatoonStorageScope class PacketHistoryActivity : LocalizedActivity() { private lateinit var actionButton: Button @@ -46,13 +48,20 @@ class PacketHistoryActivity : LocalizedActivity() { val entryId = intent.getStringExtra(EXTRA_ENTRY_ID).orEmpty() val title = intent.getStringExtra(EXTRA_ENTRY_TITLE).orEmpty() + val storageId = intent.getStringExtra(EXTRA_STORAGE_ID) + val active = PlatoonProfileRegistry(this).active() + if (storageId == null || active?.storageId != storageId) { + finish() + return + } + val historyRoot = PlatoonStorageScope(storageId).rootDirectory(this) rawContent = if (intent.getBooleanExtra(EXTRA_SAVED_ENTRY, false)) { SavedHistoryStore( - File(filesDir, SavedHistoryStore.SAVED_HISTORY_DIRECTORY), + File(historyRoot, SavedHistoryStore.SAVED_HISTORY_DIRECTORY), ).read(entryId) } else { CaptureHistoryStore( - File(filesDir, CaptureHistoryStore.HISTORY_DIRECTORY), + File(historyRoot, CaptureHistoryStore.HISTORY_DIRECTORY), ).read(entryId) } ?: run { finish() @@ -278,5 +287,6 @@ class PacketHistoryActivity : LocalizedActivity() { const val EXTRA_ENTRY_ID = "entry_id" const val EXTRA_ENTRY_TITLE = "entry_title" const val EXTRA_SAVED_ENTRY = "saved_entry" + const val EXTRA_STORAGE_ID = "storage_id" } } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index ff89c62..d6e1669 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -48,7 +48,12 @@ class PlatoonActivity : LocalizedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) profileBinding = ActivePlatoonScopeBinding(this) - repository = PlatoonRepository(this, profileBinding.scope) + val scope = profileBinding.scope ?: run { + Toast.makeText(this, R.string.no_platoon_detected_detail, Toast.LENGTH_LONG).show() + finish() + return + } + repository = PlatoonRepository(this, scope) setContentView( PrimaryNavigation.wrap( this, diff --git a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt new file mode 100644 index 0000000..e6a898e --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt @@ -0,0 +1,313 @@ +package dev.gf2log.app + +import android.app.Activity +import android.app.AlertDialog +import android.graphics.Typeface +import android.text.Editable +import android.text.InputFilter +import android.text.InputType +import android.text.TextWatcher +import android.view.Gravity +import android.view.ViewGroup +import android.widget.EditText +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.ScrollView +import android.widget.TextView +import dev.gf2log.app.management.PlatoonClient +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameServerRegion + +/** Selects or declares the explicit profile that will own an identity-free roster CSV. */ +internal object PlatoonCsvImportPrompt { + fun show(activity: Activity, onSelected: (PlatoonProfile) -> Unit) { + showSelector( + activity = activity, + initiallySelectedStorageId = PlatoonProfileRegistry(activity).active()?.storageId, + onSelected = onSelected, + ) + } + + private fun showSelector( + activity: Activity, + initiallySelectedStorageId: String?, + onSelected: (PlatoonProfile) -> Unit, + ) { + val registry = PlatoonProfileRegistry(activity) + val profiles = registry.list() + var selected = profiles.firstOrNull { it.storageId == initiallySelectedStorageId } + val choices = RadioGroup(activity).apply { + orientation = RadioGroup.VERTICAL + profiles.forEach { profile -> + addView(RadioButton(context).apply { + id = android.view.View.generateViewId() + text = profileLabel(activity, profile) + textSize = 14f + minimumHeight = activity.dp(48) + isChecked = profile.storageId == selected?.storageId + setOnCheckedChangeListener { _, checked -> + if (checked) selected = profile + } + }, matchWidth()) + } + } + val add = ImageButton(activity).apply { + setImageResource(R.drawable.ic_add_circle) + contentDescription = activity.getString(R.string.add_new_platoon_profile) + useModernIconStyle() + imageTintList = null + setPadding(activity.dp(10), activity.dp(10), activity.dp(10), activity.dp(10)) + } + val content = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(activity.dp(16), activity.dp(4), activity.dp(16), 0) + addView(choices, matchWidth()) + addView( + add, + LinearLayout.LayoutParams(activity.dp(48), activity.dp(48)).apply { + gravity = Gravity.CENTER_HORIZONTAL + topMargin = activity.dp(if (profiles.isEmpty()) 4 else 8) + }, + ) + } + val dialog = AlertDialog.Builder(activity) + .setTitle(R.string.select_platoon) + .setView(ScrollView(activity).apply { addView(content) }) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.select, null) + .create() + dialog.setOnShowListener { + val select = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { + isEnabled = selected != null + usePrimaryActionStyle() + setOnClickListener { + val target = selected ?: return@setOnClickListener + dialog.dismiss() + onSelected(target) + } + } + choices.setOnCheckedChangeListener { _, checkedId -> + val index = (0 until choices.childCount) + .indexOfFirst { choices.getChildAt(it).id == checkedId } + selected = profiles.getOrNull(index) + select.isEnabled = selected != null + } + add.setOnClickListener { + showCreate(activity, registry) { created -> + dialog.dismiss() + showSelector(activity, created.storageId, onSelected) + } + } + } + dialog.show() + } + + private fun showCreate( + activity: Activity, + registry: PlatoonProfileRegistry, + onCreated: (PlatoonProfile) -> Unit, + ) { + var client = PlatoonClient.HAOPLAY + var region: GameServerRegion? = null + val name = EditText(activity).apply { + hint = activity.getString(R.string.platoon_name) + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + filters = arrayOf(InputFilter.LengthFilter(PlatoonProfile.MAX_NAME_LENGTH)) + maxLines = 1 + setSingleLine(true) + } + val platoonIdInput = EditText(activity).apply { + hint = activity.getString(R.string.platoon_id) + inputType = InputType.TYPE_CLASS_NUMBER + maxLines = 1 + setSingleLine(true) + } + val serverChoices = RadioGroup(activity).apply { + orientation = RadioGroup.VERTICAL + } + fun rebuildServerChoices() { + region = null + serverChoices.removeAllViews() + ClientServerRegionPreferences.allowedFor(client.packageName).forEach { candidate -> + serverChoices.addView(RadioButton(activity).apply { + id = android.view.View.generateViewId() + text = serverRegionLabel(activity, candidate) + textSize = 14f + minimumHeight = activity.dp(48) + setOnCheckedChangeListener { _, checked -> + if (checked) region = candidate + } + }, matchWidth()) + } + } + rebuildServerChoices() + val clients = RadioGroup(activity).apply { + orientation = RadioGroup.HORIZONTAL + listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER).forEach { candidate -> + addView(RadioButton(context).apply { + id = android.view.View.generateViewId() + text = candidate.displayName + textSize = 14f + minimumHeight = activity.dp(48) + isChecked = candidate == client + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + } + setOnCheckedChangeListener { group, checkedId -> + val index = (0 until group.childCount) + .indexOfFirst { group.getChildAt(it).id == checkedId } + client = listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER) + .getOrElse(index) { PlatoonClient.HAOPLAY } + rebuildServerChoices() + } + } + val error = TextView(activity).apply { + textSize = 12f + setTextColor(context.getColor(R.color.destructive_action)) + visibility = android.view.View.GONE + } + val content = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(activity.dp(20), activity.dp(4), activity.dp(20), 0) + addView(TextView(context).apply { + text = activity.getString(R.string.new_platoon_entry_message) + textSize = 14f + setTextColor(context.getColor(R.color.text_secondary)) + }, matchWidth()) + addView(fieldLabel(activity, R.string.platoon_client), matchWidth().apply { + topMargin = activity.dp(16) + }) + addView(clients, matchWidth()) + addView(fieldLabel(activity, R.string.server_region), matchWidth().apply { + topMargin = activity.dp(8) + }) + addView(serverChoices, matchWidth()) + addView(fieldLabel(activity, R.string.platoon_name), matchWidth().apply { + topMargin = activity.dp(8) + }) + addView(name, matchWidth()) + addView(fieldLabel(activity, R.string.platoon_id), matchWidth().apply { + topMargin = activity.dp(8) + }) + addView(platoonIdInput, matchWidth()) + addView(error, matchWidth().apply { topMargin = activity.dp(8) }) + } + val dialog = AlertDialog.Builder(activity) + .setTitle(R.string.enter_new_platoon_profile) + .setView(ScrollView(activity).apply { addView(content) }) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.confirm, null) + .create() + dialog.setOnShowListener { + val confirm = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { + usePrimaryActionStyle() + } + fun validId(): Long? = platoonIdInput.text.toString().toLongOrNull() + ?.takeIf { it in 1L..UInt.MAX_VALUE.toLong() } + fun updateEnabled() { + confirm.isEnabled = name.text.toString().trim().isNotEmpty() && + validId() != null && + region != null + } + val watcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + error.visibility = android.view.View.GONE + updateEnabled() + } + override fun afterTextChanged(s: Editable?) = Unit + } + name.addTextChangedListener(watcher) + platoonIdInput.addTextChangedListener(watcher) + clients.setOnCheckedChangeListener { group, checkedId -> + val index = (0 until group.childCount) + .indexOfFirst { group.getChildAt(it).id == checkedId } + client = listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER) + .getOrElse(index) { PlatoonClient.HAOPLAY } + rebuildServerChoices() + serverChoices.setOnCheckedChangeListener { _, checkedRegionId -> + val regions = ClientServerRegionPreferences.allowedFor(client.packageName) + val selectedIndex = (0 until serverChoices.childCount) + .indexOfFirst { serverChoices.getChildAt(it).id == checkedRegionId } + region = regions.getOrNull(selectedIndex) + updateEnabled() + } + updateEnabled() + } + serverChoices.setOnCheckedChangeListener { _, checkedId -> + val regions = ClientServerRegionPreferences.allowedFor(client.packageName) + val index = (0 until serverChoices.childCount) + .indexOfFirst { serverChoices.getChildAt(it).id == checkedId } + region = regions.getOrNull(index) + updateEnabled() + } + confirm.setOnClickListener { + val selectedRegion = region ?: return@setOnClickListener + val platoonId = validId() ?: return@setOnClickListener + runCatching { + registry.createDeclared( + client = client, + region = selectedRegion, + platoonId = platoonId, + platoonName = name.text.toString(), + ) + }.fold( + onSuccess = { + dialog.dismiss() + onCreated(it) + }, + onFailure = { + error.text = activity.getString(R.string.unable_to_add_platoon_profile) + error.visibility = android.view.View.VISIBLE + }, + ) + } + updateEnabled() + } + dialog.show() + } + + private fun fieldLabel(activity: Activity, text: Int) = TextView(activity).apply { + setText(text) + textSize = 13f + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + setTextColor(context.getColor(R.color.text_secondary)) + } + + private fun profileLabel(activity: Activity, profile: PlatoonProfile): String = + "${profile.client.displayName} / ${regionCode(profile.serverRegion)} / " + + "${profile.platoonName} / ${profile.platoonId}" + + private fun regionCode(region: GameServerRegion): String = when (region) { + GameServerRegion.MANUAL -> "Manual" + GameServerRegion.DARKWINTER_GLOBAL, GameServerRegion.HAOPLAY_GLOBAL -> "GL" + GameServerRegion.DARKWINTER_CHINA -> "CN" + GameServerRegion.HAOPLAY_JAPAN -> "JP" + GameServerRegion.HAOPLAY_KOREA -> "KR" + GameServerRegion.HAOPLAY_ASIA -> "ASIA" + } + + private fun serverRegionLabel(activity: Activity, region: GameServerRegion): String = + activity.getString( + when (region) { + GameServerRegion.MANUAL -> R.string.server_region_manual + GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global + GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china + GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global + GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan + GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea + GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia + }, + ) + + private fun matchWidth() = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + + private fun Activity.dp(value: Int): Int = + (value * resources.displayMetrics.density).toInt() +} diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index a928c71..53c7d60 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -74,17 +74,14 @@ internal object PlatoonProfileSelector { setOnClickListener { showManagement(activity, PlatoonProfileRegistry(activity)) } } - private fun label(activity: Activity, profile: PlatoonProfile): String = if (profile.legacy) { - activity.getString(R.string.existing_platoon_data) - } else { + private fun label(activity: Activity, profile: PlatoonProfile): String = "${profile.client.displayName} / ${regionCode(profile.serverRegion)} / " + "${profile.platoonName} / ${profile.platoonId}" - } private fun showSelector(activity: Activity, registry: PlatoonProfileRegistry) { val profiles = registry.list() if (profiles.isEmpty()) { - Toast.makeText(activity, R.string.no_platoon_detected_detail, Toast.LENGTH_SHORT).show() + showNoPlatoonMessage(activity) return } val activeId = registry.active()?.storageId @@ -107,7 +104,7 @@ internal object PlatoonProfileSelector { 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() + showNoPlatoonMessage(activity) return } val activeId = registry.active()?.storageId @@ -160,16 +157,12 @@ internal object PlatoonProfileSelector { 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 "" + text = activity.getString( + R.string.profile_management_identity, + profile.client.displayName, + regionCode(profile.serverRegion), + profile.platoonId, + ) + if (active) " · ${activity.getString(R.string.active_platoon)}" else "" textSize = 12f setTextColor(context.getColor(R.color.text_secondary)) maxLines = 2 @@ -178,8 +171,7 @@ internal object PlatoonProfileSelector { }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), ) - if (!profile.legacy) { - addView(ImageButton(context).apply { + addView(ImageButton(context).apply { setImageResource(R.drawable.ic_edit) contentDescription = activity.getString( R.string.edit_platoon_server_description, @@ -190,7 +182,7 @@ internal object PlatoonProfileSelector { }, LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { marginStart = dp(activity, 4) }) - addView(ImageButton(context).apply { + addView(ImageButton(context).apply { setImageResource(R.drawable.ic_delete) imageTintList = ColorStateList.valueOf(context.getColor(R.color.destructive_action)) contentDescription = activity.getString( @@ -203,7 +195,6 @@ internal object PlatoonProfileSelector { }, LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { marginStart = dp(activity, 4) }) - } } private fun chooseProfileRegion(activity: Activity, profile: PlatoonProfile) { @@ -342,6 +333,13 @@ internal object PlatoonProfileSelector { } } + private fun showNoPlatoonMessage(activity: Activity) { + AlertDialog.Builder(activity) + .setMessage(R.string.no_platoon_detected_detail) + .setPositiveButton(android.R.string.ok, null) + .show() + } + private fun Activity.serverRegionLabel(region: GameServerRegion): String = getString( when (region) { GameServerRegion.MANUAL -> R.string.server_region_manual diff --git a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt index 4931d62..f6cbee6 100644 --- a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt +++ b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt @@ -1,6 +1,7 @@ package dev.gf2log.app import android.app.Activity +import android.app.AlertDialog import android.content.Intent import android.content.res.ColorStateList import android.graphics.Typeface @@ -12,6 +13,7 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import dev.gf2log.app.management.PlatoonProfileRegistry import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -110,11 +112,20 @@ internal object PrimaryNavigation { ).apply { topMargin = activity.dp(2) }) if (!active) { setOnClickListener { - activity.startActivity( - Intent(activity, activityClass).addFlags( - Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, - ), - ) + if (destination != Destination.HOME && + PlatoonProfileRegistry(activity).active() == null + ) { + AlertDialog.Builder(activity) + .setMessage(R.string.no_platoon_detected_detail) + .setPositiveButton(android.R.string.ok, null) + .show() + } else { + activity.startActivity( + Intent(activity, activityClass).addFlags( + Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, + ), + ) + } } } }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index c8bda3c..a61ffd5 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -123,7 +123,12 @@ class WeeklyReportActivity : LocalizedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) profileBinding = ActivePlatoonScopeBinding(this) - repository = PlatoonRepository(this, profileBinding.scope) + val scope = profileBinding.scope ?: run { + Toast.makeText(this, R.string.no_platoon_detected_detail, Toast.LENGTH_LONG).show() + finish() + return + } + repository = PlatoonRepository(this, scope) pendingPng = WeeklyPngPendingState.restore( cacheDir, savedInstanceState?.getString(STATE_PENDING_PNG_NAME), @@ -134,7 +139,7 @@ class WeeklyReportActivity : LocalizedActivity() { ?.let(LocalDate::ofEpochDay) ?: PlatoonPeriods.gameDay( Instant.now(), - GameTimeZonePreferences.get(this, profileBinding.scope.storageId), + GameTimeZonePreferences.get(this, scope.storageId), ), ) body = LinearLayout(this).apply { 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 4fecaca..a118ab1 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -60,7 +60,7 @@ class CaptureVpnService : VpnService() { private val captureChecklist = ScopedCaptureChecklist(REQUIRED_CAPTURE_TYPES) private val profileAdmissionGate = PlatoonProfilePolicy.AdmissionGate() private val mainHandler = Handler(Looper.getMainLooper()) - private lateinit var historyStore: CaptureHistoryStore + private val historyStores = ConcurrentHashMap() private lateinit var profileRegistry: PlatoonProfileRegistry private lateinit var clientServerRegions: ClientServerRegionPreferences private lateinit var payloadHistoryPreferences: PayloadHistoryPreferences @@ -92,9 +92,6 @@ class CaptureVpnService : VpnService() { super.onCreate() profileRegistry = PlatoonProfileRegistry(this) clientServerRegions = ClientServerRegionPreferences(this) - historyStore = CaptureHistoryStore( - File(filesDir, CaptureHistoryStore.HISTORY_DIRECTORY), - ) payloadHistoryPreferences = PayloadHistoryPreferences(this) capturePreferences = CapturePreferences(this) diagnosticsStore = CaptureDiagnosticsStore(this) @@ -374,12 +371,6 @@ class CaptureVpnService : VpnService() { flowEnded = true, ) } - if ( - !pendingAdmissionByFlow.containsKey(flowId) && - !flowSessions.containsKey(flowId) - ) { - pendingFlowPayloads.take(flowId).forEach(::saveHistoryOnly) - } closeFlowSession(flowId) } ) { @@ -740,7 +731,7 @@ class CaptureVpnService : VpnService() { payload: ParsedPayload, flowEnded: Boolean = false, ) { - saveHistoryOnly(payload) + saveHistoryOnly(session.profile.storageId, payload) routePayload(session, payload, flowEnded) if (payload.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { markRequiredPayloadCaptured( @@ -750,8 +741,14 @@ class CaptureVpnService : VpnService() { } } - private fun saveHistoryOnly(payload: ParsedPayload) { + private fun saveHistoryOnly(storageId: String, payload: ParsedPayload) { if (!payloadHistoryPreferences.isEnabled(payload.payloadType)) return + val historyStore = historyStores.computeIfAbsent(storageId) { + val scope = PlatoonStorageScope(storageId) + CaptureHistoryStore( + File(scope.rootDirectory(this), CaptureHistoryStore.HISTORY_DIRECTORY), + ) + } runCatching { historyStore.save(payload) } .onFailure { CaptureStatus.update("Unable to save parsed-packet history") } } 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 2713d0c..6c48162 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -24,7 +24,7 @@ class PlatoonBackupManager internal constructor( private val storageScope: PlatoonStorageScope = PlatoonProfileRegistry(context).activeScope(), ) { - private constructor(context: Context, storageScope: PlatoonStorageScope) : this( + internal constructor(context: Context, storageScope: PlatoonStorageScope) : this( context = context, settingsStore = ScopedAppSettingsStore( context.applicationContext, @@ -187,17 +187,16 @@ class PlatoonBackupManager internal constructor( private fun managerFor(staged: BackupArchive.StagedArchive): RestoreTarget { val registry = PlatoonProfileRegistry(appContext) - val restoredProfile = staged.profile?.toProfile() - restoredProfile?.let(registry::requireRestoreCapacity) - val scope = restoredProfile?.storageId - ?.let(::PlatoonStorageScope) - ?: PlatoonStorageScope(PlatoonProfileIdentity.LEGACY_STORAGE_ID) + val restoredProfile = requireNotNull(staged.profile) { + "Unscoped legacy backups are no longer supported" + }.toProfile() + require(!restoredProfile.legacy) { "Unscoped legacy backups are no longer supported" } + registry.requireRestoreCapacity(restoredProfile) + val scope = PlatoonStorageScope(restoredProfile.storageId) val previousProfile = registry.find(scope.storageId) val previousActiveStorageId = registry.active()?.storageId val clientRegions = ClientServerRegionPreferences(appContext) - val previousCaptureRegion = restoredProfile - ?.takeUnless(PlatoonProfile::legacy) - ?.let { clientRegions.stored(it.client.packageName) } + val previousCaptureRegion = clientRegions.stored(restoredProfile.client.packageName) val manager = if (scope == storageScope) { this } else { @@ -230,16 +229,14 @@ class PlatoonBackupManager internal constructor( targetStorageId = manager.storageScope.storageId, previousProfile = previousProfile, previousActiveStorageId = previousActiveStorageId, - ownerPackage = restoredProfile - ?.takeUnless(PlatoonProfile::legacy) - ?.client - ?.packageName, + ownerPackage = restoredProfile?.client?.packageName, previousCaptureRegion = previousCaptureRegion, ) fun installProfileMetadataAndActivate() { - val storageId = restoredProfile?.let(registry::upsertRestored)?.storageId - ?: registry.ensureLegacyProfile().storageId + val storageId = requireNotNull(restoredProfile) + .let(registry::upsertRestored) + .storageId check(registry.setActive(storageId)) { "Unable to select the restored Platoon" } @@ -247,19 +244,18 @@ class PlatoonBackupManager internal constructor( } 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" - } + val archivedProfile = requireNotNull(staged.profile) { + "Unscoped legacy backups are no longer supported" + } + require(!archivedProfile.legacy) { "Unscoped legacy backups are no longer supported" } + require(archivedProfile.storageId == storageScope.storageId) { + "Backup belongs to a different Platoon" } } private fun backupProfile(): PlatoonProfile { val registry = PlatoonProfileRegistry(appContext) - if (storageScope.isLegacy) registry.ensureLegacyProfile() else registry.ensureInitialized() + registry.ensureInitialized() return requireNotNull(registry.find(storageScope.storageId)) { "The selected Platoon profile is unavailable" } @@ -411,7 +407,7 @@ class PlatoonBackupManager internal constructor( private fun validateCurrentSchema(database: SQLiteDatabase) { appContext.deleteDatabase(SCHEMA_REFERENCE_DATABASE) try { - PlatoonDatabase(appContext, SCHEMA_REFERENCE_DATABASE).use { helper -> + PlatoonDatabase(appContext, SCHEMA_REFERENCE_DATABASE, storageScope).use { helper -> val expected = DatabaseSchemaContract.read(helper.readableDatabase) val actual = DatabaseSchemaContract.read(database) require(actual == expected) { @@ -552,6 +548,52 @@ class PlatoonBackupManager internal constructor( private const val RESTORE_PROFILE_FILE = "profile.pre_restore" private val SQLITE_HEADER = "SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) + /** Restores a scoped archive even when a fresh installation has no active profile. */ + fun restoreSelected( + context: Context, + input: InputStream, + complete: Boolean, + ) { + val appContext = context.applicationContext + val stagingDirectory = File(appContext.cacheDir, "platoon-restore-selected").apply { + check(mkdirs() || isDirectory) { "Unable to create the restore staging directory" } + } + val stagedDatabase = File.createTempFile("selected-", ".db", stagingDirectory) + try { + val staged = try { + BackupArchive.stage(input, stagedDatabase) + } catch (error: IllegalArgumentException) { + throw InvalidBackupException(error) + } catch (error: ZipException) { + throw InvalidBackupException(error) + } catch (error: EOFException) { + throw InvalidBackupException(error) + } + val profile = try { + requireNotNull(staged.profile) { + "Unscoped legacy backups are no longer supported" + }.toProfile().also { + require(!it.legacy) { "Unscoped legacy backups are no longer supported" } + } + } catch (error: IllegalArgumentException) { + throw InvalidBackupException(error) + } + val manager = PlatoonBackupManager( + appContext, + PlatoonStorageScope(profile.storageId), + ) + val target = manager.managerFor(staged) + if (complete) { + manager.restoreStagedComplete(stagedDatabase, staged, target) + } else { + manager.restoreStagedPlatoon(stagedDatabase, staged, target) + } + } finally { + stagedDatabase.delete() + stagingDirectory.delete() + } + } + internal fun recoverInterruptedFullRestore( context: Context, settingsStore: BackupSettingsStore? = null, 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 bac5477..5772106 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -12,9 +12,11 @@ import java.time.LocalDate import java.time.ZoneId import java.util.Locale -class PlatoonDatabase( +internal class PlatoonDatabase( context: Context, - databaseName: String = PlatoonSchema.DATABASE_NAME, + databaseName: String = PlatoonProfileRegistry(context).activeScope().databaseName, + private val storageScope: PlatoonStorageScope = + PlatoonStorageScope.fromDatabaseName(databaseName), ) : SQLiteOpenHelper( context.applicationContext, @@ -23,8 +25,6 @@ class PlatoonDatabase( PlatoonSchema.CURRENT_VERSION, ) { private val appContext = context.applicationContext - private val storageScope = PlatoonStorageScope.fromDatabaseName(databaseName) - override fun onConfigure(db: SQLiteDatabase) { super.onConfigure(db) db.setForeignKeyConstraintsEnabled(true) 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 5885a4d..f00207b 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt @@ -119,11 +119,9 @@ internal data class PlatoonStorageScope(val storageId: String) { PlatoonSchema.DATABASE_NAME -> PlatoonStorageScope( PlatoonProfileIdentity.LEGACY_STORAGE_ID, ) - else -> SCOPED_DATABASE.matchEntire(databaseName) - ?.groupValues - ?.get(1) - ?.let(::PlatoonStorageScope) - ?: PlatoonStorageScope(PlatoonProfileIdentity.LEGACY_STORAGE_ID) + else -> requireNotNull(SCOPED_DATABASE.matchEntire(databaseName)) { + "Database name is not a recognized Platoon scope" + }.groupValues[1].let(::PlatoonStorageScope) } } } @@ -135,35 +133,13 @@ internal class PlatoonProfileRegistry(context: Context) { fun ensureInitialized(): List = synchronized(lock) { PlatoonProfileAdministration.recoverPending(appContext) - 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 + retireLegacyMetadataLocked() + readAllLocked().filterNot(PlatoonProfile::legacy) } fun list(): List = synchronized(lock) { ensureInitialized() - readAllLocked().sortedWith( + readAllLocked().filterNot(PlatoonProfile::legacy).sortedWith( compareByDescending { it.lastSeenAt }.thenBy { it.storageId }, ) } @@ -171,13 +147,14 @@ internal class PlatoonProfileRegistry(context: Context) { fun active(): PlatoonProfile? = synchronized(lock) { ensureInitialized() val activeId = preferences.getString(KEY_ACTIVE, null) - readAllLocked().firstOrNull { it.storageId == activeId } - ?: readAllLocked().maxByOrNull(PlatoonProfile::lastSeenAt) + val current = readAllLocked().filterNot(PlatoonProfile::legacy) + current.firstOrNull { it.storageId == activeId } + ?: current.maxByOrNull(PlatoonProfile::lastSeenAt) } fun find(storageId: String): PlatoonProfile? = synchronized(lock) { ensureInitialized() - readLocked(storageId) + readLocked(storageId)?.takeUnless(PlatoonProfile::legacy) } fun findByIdentity( @@ -204,15 +181,14 @@ internal class PlatoonProfileRegistry(context: Context) { } } - fun activeScope(): PlatoonStorageScope = - PlatoonStorageScope(active()?.storageId ?: PlatoonProfileIdentity.LEGACY_STORAGE_ID) + fun activeScope(): PlatoonStorageScope = PlatoonStorageScope( + requireNotNull(active()) { "No confirmed Platoon profile is selected" }.storageId, + ) fun setActive(storageId: String): Boolean = synchronized(lock) { require(PlatoonProfileIdentity.isValidStorageId(storageId)) val selected = readLocked(storageId) ?: return@synchronized false - if (selected.legacy) { - return@synchronized preferences.edit().putString(KEY_ACTIVE, storageId).commit() - } + if (selected.legacy) return@synchronized false val clientRegions = ClientServerRegionPreferences(appContext) val ownerPackage = selected.client.packageName val previousRegion = clientRegions.stored(ownerPackage) @@ -270,6 +246,45 @@ internal class PlatoonProfileRegistry(context: Context) { profile } + /** + * Creates an isolated profile from identity fields explicitly supplied by the user. + * This is the only safe destination for a roster CSV that contains no 21905 identity. + */ + fun createDeclared( + client: PlatoonClient, + region: GameServerRegion, + platoonId: Long, + platoonName: String, + createdAt: Instant = Instant.now(), + ): PlatoonProfile = synchronized(lock) { + require(client != PlatoonClient.LEGACY) { "A supported client is required" } + require(region in ClientServerRegionPreferences.allowedFor(client.packageName)) { + "The server region does not belong to this client" + } + require(platoonId in 1L..UInt.MAX_VALUE.toLong()) { "Platoon ID is invalid" } + require(readAllLocked().none { + !it.legacy && + it.client == client && + it.serverRegion == region && + it.platoonId == platoonId + }) { "That client/server Platoon profile already exists" } + require(readAllLocked().size < MAX_PROFILES) { + "Too many Platoon profiles are already registered" + } + val profile = PlatoonProfile( + storageId = allocateStorageIdLocked(client, region, platoonId), + client = client, + serverRegion = region, + platoonId = platoonId, + platoonName = normalizeName(platoonName), + emblemPrimary = emptyList(), + emblemSecondary = emptyList(), + lastSeenAt = createdAt, + ) + writeLocked(profile, setActive = false) + profile + } + /** Updates only mutable observed profile fields while retaining the isolated storage scope. */ fun updateObserved( storageId: String, @@ -326,6 +341,7 @@ internal class PlatoonProfileRegistry(context: Context) { } fun upsertRestored(profile: PlatoonProfile): PlatoonProfile = synchronized(lock) { + require(!profile.legacy) { "Legacy unscoped Platoon data cannot be restored" } requireCompatibleRestoreTargetLocked(profile) require(readLocked(profile.storageId) != null || readAllLocked().size < MAX_PROFILES) { "Too many Platoon profiles are already registered" @@ -336,6 +352,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) { + require(!profile.legacy) { "Legacy unscoped Platoon data cannot be restored" } requireCompatibleRestoreTargetLocked(profile) require(readLocked(profile.storageId) != null || readAllLocked().size < MAX_PROFILES) { "Too many Platoon profiles are already registered" @@ -443,6 +460,29 @@ internal class PlatoonProfileRegistry(context: Context) { .orEmpty() .mapNotNull(::readLocked) + /** Removes the pre-isolation selector only; its files remain quarantined for recovery. */ + private fun retireLegacyMetadataLocked() { + val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() + if (!ids.remove(PlatoonProfileIdentity.LEGACY_STORAGE_ID) && + preferences.getString(KEY_ACTIVE, null) != PlatoonProfileIdentity.LEGACY_STORAGE_ID + ) return + val prefix = "$KEY_PROFILE.${PlatoonProfileIdentity.LEGACY_STORAGE_ID}." + val editor = preferences.edit() + .putStringSet(KEY_IDS, ids) + .remove(prefix + CLIENT) + .remove(prefix + REGION) + .remove(prefix + PLATOON_ID) + .remove(prefix + NAME) + .remove(prefix + EMBLEM_PRIMARY) + .remove(prefix + EMBLEM_SECONDARY) + .remove(prefix + LAST_SEEN) + .remove(prefix + LEGACY) + if (preferences.getString(KEY_ACTIVE, null) == PlatoonProfileIdentity.LEGACY_STORAGE_ID) { + editor.remove(KEY_ACTIVE) + } + check(editor.commit()) { "Unable to retire legacy Platoon metadata" } + } + private fun readLocked(storageId: String): PlatoonProfile? = runCatching { if (!PlatoonProfileIdentity.isValidStorageId(storageId)) return@runCatching null val prefix = "$KEY_PROFILE.$storageId." @@ -478,6 +518,7 @@ internal class PlatoonProfileRegistry(context: Context) { } private fun requireCompatibleRestoreTargetLocked(profile: PlatoonProfile) { + require(!profile.legacy) { "Legacy unscoped Platoon data cannot be restored" } val profiles = readAllLocked() profiles.firstOrNull { it.storageId == profile.storageId }?.let { existing -> require( @@ -530,18 +571,6 @@ internal class PlatoonProfileRegistry(context: Context) { .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" @@ -556,7 +585,6 @@ internal class PlatoonProfileRegistry(context: Context) { private const val EMBLEM_SECONDARY = "emblem_secondary" private const val LAST_SEEN = "last_seen" private const val LEGACY = "legacy" - private 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 187933b..affb54e 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -548,9 +548,7 @@ internal class PlatoonRepository( } internal fun withExclusiveDatabase( - scope: PlatoonStorageScope = PlatoonStorageScope( - PlatoonProfileIdentity.LEGACY_STORAGE_ID, - ), + scope: PlatoonStorageScope, block: () -> T, ): T = maintenanceLock.writeLock().withLock { diff --git a/app/src/main/res/drawable/ic_add_circle.xml b/app/src/main/res/drawable/ic_add_circle.xml new file mode 100644 index 0000000..9c4919a --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1c1b588..5a1578f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -386,7 +386,9 @@ 긴 공백 때문에 일일 배치를 하나로 확정할 수 없습니다. 가능한 배치가 둘 이상이므로 애플리케이션은 임의로 추측하지 않습니다. CSV 가져오기 검토 + 적용 대상: %1$s (ID %2$d)\n%3$s · %4$s\n\n21917만으로는 적용 대상을 확인할 수 없습니다. 선택한 서클이 맞는지 확인하세요. 검증된 파일: %1$d\n이미 존재함: %2$d\n과거 캡처: %3$d\n포함된 멤버: %4$d\n새 UID: %5$d\n이름 차이: %6$d\n예상 가입: %7$d\n예상 탈퇴: %8$d\n선택 용량: %9$d KiB\n캡처 범위: %10$s\n\n아직 데이터는 변경되지 않았습니다. 가져오기 전에 자동 체크포인트를 생성합니다. + 선택한 서클이 변경되었거나 더 이상 존재하지 않습니다. CSV 데이터는 가져오지 않았습니다. 적용할 서클을 다시 선택한 뒤 시도하세요. %1$s — %2$s CSV 파일을 검증하고 미리보기를 준비하는 중… 체크포인트를 만들고 CSV 파일을 가져오는 중… @@ -438,13 +440,20 @@ 이 표 히스토리 항목을 더 이상 사용할 수 없습니다. 복원한 과거 주간 표를 표시하고 있습니다. 새 근거가 들어오면 최신 표로 돌아갑니다. 서클 선택 + 선택 + 새 서클 프로필 추가 + 신규 서클 정보 입력 + 새롭게 등록할 서클의 정보를 입력해 주세요. + 클라이언트 + 서클 이름 + 서클 ID + 모든 항목을 확인하세요. 같은 클라이언트, 서버 및 서클 ID가 이미 등록되어 있을 수 있습니다. 프로필 잊기 프로필 수용량을 확보하기 위해 이 서클을 선택 목록에서 제거할까요? 비공개 데이터는 유지되며, 해당 서클을 다시 감지하거나 복원하면 돌아옵니다. 잊기 이 서클 프로필을 제거할 수 없습니다 감지된 서클 없음 - 지원 클라이언트에서 서클 프로필 패킷이 도착할 때까지 캡처하세요. - 기존 서클 데이터 + 아직 수집된 서클 정보가 없습니다. HaoPlay 캡처 서버 Darkwinter 캡처 서버 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ddbcfa9..e3936e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -390,7 +390,9 @@ A long gap prevents a unique daily allocation. More than one valid allocation remains, so the app does not guess. Review CSV import + Destination: %1$s (ID %2$d)\n%3$s · %4$s\n\nPayload 21917 cannot verify this destination. Confirm that the selected Platoon is correct. Validated files: %1$d\nAlready present: %2$d\nHistorical captures: %3$d\nMembers represented: %4$d\nNew member UIDs: %5$d\nName differences: %6$d\nPotential joins: %7$d\nPotential withdrawals: %8$d\nSelected size: %9$d KiB\nCapture range: %10$s\n\nNo data has been changed. Importing creates an automatic checkpoint first. + The selected Platoon changed or is no longer available. No CSV data was imported. Select the destination Platoon and try again. %1$s — %2$s Validating CSV files and preparing preview… Creating checkpoint and importing CSV files… @@ -442,13 +444,20 @@ This table history entry is no longer available. Showing a restored table history entry. New evidence will return this week to the live table. Select platoon + Select + Add a new Platoon profile + Enter New Platoon Information + Enter the identity of the Platoon that will receive this CSV. + Client + Platoon name + Platoon ID + Check every field. This client, server, and Platoon ID may already be registered. Forget profile Remove this platoon from the selectors to recover profile capacity? Its private data is retained and returns if the platoon is detected or restored again. Forget Unable to forget this platoon profile No platoon detected - Capture a supported client until the platoon profile arrives. - Existing platoon data + No Platoon information has been collected yet. HaoPlay capture server Darkwinter capture server diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 316767f..8e08656 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -90,9 +90,12 @@ 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. +be corrected only within that client's compatible regions. Unscoped v2.3.x +files remain untouched for forensic safety but are not registered, selectable, +or reachable through production management paths. Captured packets require a +confirmed `21905` identity before a scope can be admitted. An identity-free +roster CSV can instead target an existing scope or a new profile whose complete +client/server/name/ID identity the user explicitly declares in the import flow. Choosing an existing profile synchronizes that client's capture preset to the profile's saved region; the report reset follows it automatically. Reliable @@ -179,6 +182,16 @@ repository batches a conservative roster replay after ingestion: it derives only snapshot-supported presence spans, creates inactive historical identities, and preserves manual or exact Updates boundaries. +Payload `21917` and its roster CSV projection do not contain a Platoon ID, name, +client, or server region. CSV import therefore cannot discover or guess a +profile. The user must explicitly select an existing profile or declare the +client, compatible server, Platoon name, and Platoon ID for a new isolated +profile. A later matching `21905` observation reuses that same immutable scope +and refreshes its observed name and emblem. The preview names the destination +and warns that the roster itself cannot verify the choice. If the target +disappears or changes before confirmation, the import aborts without retaining +evidence or mutating SQLite. + Roster CSV input is capped at 2 MiB, 256 members, 258 records, 9 columns, and 512 characters per field; UIDs must be unique and names are capped at 256 characters. Activity ingestion accepts at most 250 distinct observations per @@ -285,26 +298,31 @@ let Activities issue SQL directly. ## Parsed-packet history -Every completed recognized payload is formatted in protocol order and written atomically to the app's private `files/capture-history` directory. `CaptureHistoryStore` returns entries newest-first, trims the oldest files once the count exceeds 100, rejects path-like identifiers, and supports explicit deletion of user-selected entries. `SavedHistoryStore` atomically copies selected entries into `files/saved-history`, rejects duplicates, caps the collection at 50 without FIFO deletion, and keeps saved entries independent from recent-history rotation. +Every completed recognized payload is formatted in protocol order and written +atomically below the admitted profile's private +`files/platoons//capture-history` directory. Unconfirmed payloads +never enter history. `CaptureHistoryStore` returns entries newest-first, trims +the oldest files once the count exceeds 100, rejects path-like identifiers, and +supports explicit deletion of user-selected entries. `SavedHistoryStore` +atomically copies selected entries into the same profile's `saved-history` +directory, rejects duplicates, caps the collection at 50 without FIFO deletion, +and keeps saved entries independent from recent-history rotation. The main activity renders both collections with timestamp-only titles in `yy/MM/dd HH:mm:ss` using the Android device timezone. Selecting a title opens a cleaned table parsed from the stored CSV body; the same screen can reveal the complete raw stored text and copy it to the clipboard. Android backup rules exclude all private files, including recent history, saved history, generated Platoon CSV files, and the structured management database. A user can separately invoke the explicit Platoon backup export, which contains parsed management data but never raw traffic. ## Explicit backup boundary The canonical `.gf2backup` container is a bounded ZIP with a checksummed -manifest and SQLite management database. Legacy format v1 remains a -Platoon-only compatibility backup; restoring it atomically retires unrelated -target-device roster CSV evidence so later reconciliation cannot mutate the -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 an archive to its immutable storage scope and its +manifest and SQLite management database. Formats v1 and v2 predate immutable +Platoon identity and are now rejected because choosing a destination for them +could cross profile boundaries. 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. +directories are untouched. Unscoped v1/v2 archives are rejected because they +cannot prove a destination profile and would violate cross-profile isolation. +Because the manifest carries the scoped identity, a v3 archive can restore its +profile on an installation that does not yet have any registered profiles. Complete restore validates the filename, archive entries and identity, checksums, settings completeness and ranges, current database schema, SQLite diff --git a/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt b/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt index ee15f95..ddd48af 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt @@ -15,6 +15,7 @@ object ParsedPayloadTextFormatter { appendLine("capturedAt=$capturedAt") appendLine("messageId=${payload.messageId}") appendLine("payloadType=${payload.payloadType}") + appendLine("isEndOfMessage=${payload.isEndOfMessage}") appendLine() when (val data = payload.data) {