diff --git a/CHANGELOG.md b/CHANGELOG.md index e678878..b52c11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ All notable changes to mobileGF2logger are documented here. +## 2.3.3 - 2026-08-24 + +### Added + +- Add required Platoon Profile (`21905`) decoding for bounded platoon ID, name, + and emblem/style identifiers, plus original-flow owner attribution on Android + 10 and newer as the safe foundation for future multi-Platoon isolation. +- Add server-region reset presets and phone-local reset conversion, retain a + manual game-timezone fallback, and include the selection in complete backups. +- Add deterministic post-condition audits for membership timelines and weekly + revisions, plus randomized sparse Gunsmoke solver coverage. + +### Changed + +- Store each weekly-table history entry as a complete immutable render + revision, including the names, private notes, weekly notes, and membership + events visible at that time. +- Upgrade the management database to schema v13 and the complete-settings + payload to schema v4 while preserving older database and backup formats. + +### Fixed + +- Keep member active status synchronized after adding, editing, deleting, or + reopening membership periods, and reject overlapping periods or multiple + open periods before committing a manual edit. +- Validate date-only membership boundaries by their recorded calendar dates, + rather than by the unrelated time when those dates were entered. +- Repair stale active-member flags when upgrading an existing database. +- Rebuild history safely when the configured game timezone changes, and avoid + mixing restored historical tables with current member facts. +- Keep committed member, packet, note, and override changes authoritative if a + derived history refresh fails, and fall back from stale restored projections. + ## 2.3.2 - 2026-08-23 ### Added diff --git a/README.md b/README.md index 0ec9c29..bf3a398 100644 --- a/README.md +++ b/README.md @@ -16,19 +16,19 @@ never stored. ## Features -- Captures the mandatory Members (`21917`), Activity (`21935`), and Updates (`21960`) responses without a computer or root access. -- Tracks active and withdrawn members, repeat membership periods, exact Updates timestamps, editable nicknames, and private notes. +- Captures the mandatory Platoon Profile (`21905`), Members (`21917`), Activity (`21935`), and Updates (`21960`) responses without a computer or root access. +- Tracks active and withdrawn members, non-overlapping repeat membership periods, exact Updates timestamps, editable nicknames, and private notes. - Builds Sunday-to-Saturday Standard or Gunsmoke Frontline weekly tables around the 05:00 game reset, with cut-off points and manual correction for missing data. -- Offers One-time Capture that tracks the three useful Platoon payloads and stops automatically when the checklist is complete. +- Offers One-time Capture that tracks the four useful Platoon payloads and stops automatically when the checklist is complete. - Explains every weekly cell on tap and summarizes missing or uncertain evidence in an Evidence Health panel. -- Keeps up to 15 automatic revisions per weekly table so an earlier projection can be previewed and restored after an accidental import. +- 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. - Saves or shares a weekly PNG with opt-in controls for names, UIDs, and private notes. - Can send a validated original CSV to an optional user-owned Discord incoming webhook after confirmation. - Stores the latest 100 parsed packets and up to 50 saved packets, with table and raw views, copy, export, selection, and deletion. - Supports member sorting, persistent drag ordering, snapshot comparison, single-week and all-week CSV export, and complete `.gf2backup` export/restore. - Guides first-time users through Main, Settings, Platoon management, weekly controls, and parsed-packet pages, with a persistent English/Korean selector and Skip action. -- Supports English and Korean, System/Light/Dark themes, and the Android device timezone for display. +- Supports English and Korean, System/Light/Dark themes, explicit Darkwinter/HaoPlay server-region reset presets converted to the phone timezone, and a persistent manual game-timezone fallback. - Registers the HaoPlay (`com.haoplay.game.and.exilium`) and Darkwinter (`com.Sunborn.SnqxExilium.Glo`) Android clients as separate VPN targets. - Creates UTF-8 Platoon-member CSV files with this column order: diff --git a/README_KR.md b/README_KR.md index d4747ff..437555a 100644 --- a/README_KR.md +++ b/README_KR.md @@ -10,19 +10,19 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android ## 기능 -- 컴퓨터나 루트 권한 없이 필수 멤버(`21917`), 활동(`21935`), 동향(`21960`) 응답을 캡처합니다. -- 가입 및 탈퇴 멤버, 반복 가입 이력, 동향의 정확한 시각, 수정 가능한 닉네임과 개인 비고를 관리합니다. +- 컴퓨터나 루트 권한 없이 필수 서클 프로필(`21905`), 멤버(`21917`), 활동(`21935`), 동향(`21960`) 응답을 캡처합니다. +- 가입 및 탈퇴 멤버, 서로 겹치지 않는 반복 가입 이력, 동향의 정확한 시각, 수정 가능한 닉네임과 개인 비고를 관리합니다. - 05:00 게임 초기화 시각을 기준으로 일요일부터 토요일까지의 일반 주간 또는 흙먼지 주간 표를 만들며, 커트라인과 누락 데이터 수동 수정을 지원합니다. -- 한 번만 캡처는 세 가지 유용한 서클 페이로드의 수집 상태를 표시하고 체크리스트가 완료되면 자동으로 중지합니다. +- 한 번만 캡처는 네 가지 유용한 서클 페이로드의 수집 상태를 표시하고 체크리스트가 완료되면 자동으로 중지합니다. - 모든 주간 셀을 누르면 근거를 설명하고, 근거 상태 패널에서 누락되거나 불확실한 데이터를 요약합니다. -- 주간 표마다 자동 기록을 최대 15개 보관하여 잘못 가져온 뒤에도 이전 표를 미리 보고 복원할 수 있습니다. +- 주간 표마다 표시 당시의 멤버 문맥까지 포함한 전체 자동 기록을 최대 15개 보관하여 잘못 가져온 뒤에도 이전 표를 미리 보고 복원할 수 있습니다. - 중단된 가져오기를 먼저 복구한 뒤 명단 CSV의 영향을 미리 보여 주고, 실행 취소용 1단계 자동 체크포인트를 보관합니다. - 이름, UID, 비공개 메모의 포함 여부를 선택해 주간 PNG를 저장하거나 공유할 수 있습니다. - 확인 후 검증된 원본 CSV를 사용자가 소유한 선택적 Discord 수신 웹훅으로 전송할 수 있습니다. - 최근 파싱 패킷 100개와 저장 패킷 50개를 보관하며, 표 및 원본 보기, 복사, 내보내기, 선택, 삭제를 지원합니다. - 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 서클 관리 데이터 백업 및 복원을 지원합니다. - 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다. -- 영어와 한국어, 시스템/라이트/다크 테마를 지원하며 화면에 표시하는 시각에는 Android 기기의 시간대를 사용합니다. +- 영어와 한국어, 시스템/라이트/다크 테마를 지원합니다. Darkwinter/HaoPlay 서버 지역별 초기화 시각을 기기 시간대로 환산하며, 예외 상황에는 게임 시간대를 수동으로 설정할 수 있습니다. - HaoPlay(`com.haoplay.game.and.exilium`)와 Darkwinter(`com.Sunborn.SnqxExilium.Glo`) Android 클라이언트를 별도의 VPN 대상으로 등록합니다. - 다음 열 순서의 UTF-8 서클 멤버 CSV 파일을 만듭니다. diff --git a/SECURITY.md b/SECURITY.md index 7fef732..7f8673c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,6 +44,13 @@ plaintext protocol. Parsed data and backups therefore provide local integrity and management convenience, not cryptographic proof that a remote payload is genuine. +On Android 10 and newer, the active VPN may ask Android for the UID owning an +original connection tuple and map that UID only to the fixed supported package +IDs. Remote IP addresses and DNS/SNI labels are diagnostic hints, not trusted +client or server identities. Payload `21905` supplies a bounded Platoon identity +for its own decoded flow, but multi-Platoon persistence remains disabled until +every database, CSV, import, backup, and UI path can enforce the same scope. + Exports and Discord sends are explicit user actions that move selected data out of Android private storage. Backups are checksummed and strictly validated but are not encrypted or signed. Treat exported files and webhook destinations as @@ -51,7 +58,8 @@ sensitive. Weekly history remains inside the private SQLite database. Its report payloads use a versioned bounded format, reject oversized compressed or decompressed -content, and are never exposed through the PNG `FileProvider` unless the user +content, validate the complete immutable rendering context before storage or +restore, and are never exposed through the PNG `FileProvider` unless the user separately chooses the existing share workflow. Packet-triggered weekly-history generation consumes only the same validated, diff --git a/app/build.gradle b/app/build.gradle index 2cf8cc2..5b1fdda 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,8 +154,8 @@ android { applicationId = 'dev.gf2log' minSdk = 26 targetSdk = 36 - versionCode = 20302 - versionName = '2.3.2' + versionCode = 20303 + versionName = '2.3.3' testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner' ndk { diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt index a3d3e4b..10c0120 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -652,6 +652,8 @@ class PlatoonBackupManagerIntegrationTest { ) = AppBackupSettings( language = language, themeMode = "system", + gameServerRegion = dev.gf2log.app.settings.GameServerRegion.MANUAL.storedValue, + gameTimeZoneId = "Asia/Seoul", onboardingCompleted = true, detailedNotifications = detailedNotifications, targetPackage = targetPackage, 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 a945c0e..f1b152e 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -567,6 +567,44 @@ class PlatoonDatabaseIntegrationTest { } } + @Test + fun schemaTwelveUpgradeRepairsDerivedActiveState() { + val databaseName = "platoon-v12-membership-state-test.db" + context.deleteDatabase(databaseName) + try { + PlatoonDatabase(context, databaseName).use { helper -> + val writable = helper.writableDatabase + writable.execSQL( + "INSERT INTO members(" + + "uid, current_name, current_level, is_active, first_seen_at, last_seen_at" + + ") VALUES(?, ?, ?, ?, ?, ?)", + arrayOf(TARGET_UID, "Target", 60, 0, 1_000L, 1_000L), + ) + writable.execSQL( + "INSERT INTO membership_periods(" + + "uid, joined_at, joined_precision, joined_source" + + ") VALUES(?, ?, ?, ?)", + arrayOf( + TARGET_UID, + 1_000L, + EvidencePrecision.EXACT.name, + EvidenceSource.GAME_UPDATES.name, + ), + ) + } + context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null).use { legacy -> + legacy.version = 12 + } + + PlatoonDatabase(context, databaseName).use { upgraded -> + assertEquals(PlatoonSchema.CURRENT_VERSION, upgraded.writableDatabase.version) + assertTrue(upgraded.listMemberStatuses().single().isActive) + } + } finally { + context.deleteDatabase(databaseName) + } + } + @Test fun exactUpdatesSynchronizeCurrentStateWithoutOverridingNewerRoster() { val rosterAt = Instant.parse("2026-07-31T00:00:00Z") @@ -904,6 +942,74 @@ class PlatoonDatabaseIntegrationTest { assertForeignKeysValid() } + @Test + fun manualMembershipMutationsKeepCurrentStateAndChronologyConsistent() { + val joined = MembershipBoundaryValue( + LocalDate.of(2026, 1, 1), + Instant.parse("2026-01-01T00:00:00Z"), + timeKnown = true, + ) + val withdrew = MembershipBoundaryValue( + LocalDate.of(2026, 2, 1), + Instant.parse("2026-02-01T00:00:00Z"), + timeKnown = true, + ) + assertTrue(database.addWithdrawnMember(TARGET_UID, "Target", joined, withdrew, "")) + assertFalse(database.listMemberStatuses().single().isActive) + + val rejoined = MembershipBoundaryValue( + LocalDate.of(2026, 3, 1), + Instant.parse("2026-03-01T00:00:00Z"), + timeKnown = true, + ) + assertTrue(database.addMembershipPeriod(TARGET_UID, rejoined, null, "Current")) + var status = database.listMemberStatuses().single() + assertTrue(status.isActive) + val currentId = status.membershipPeriods.single { it.note == "Current" }.id + + val secondWithdrawal = MembershipBoundaryValue( + LocalDate.of(2026, 4, 1), + Instant.parse("2026-04-01T00:00:00Z"), + timeKnown = true, + ) + assertTrue(database.updateMembershipPeriod(currentId, rejoined, secondWithdrawal, "Closed")) + status = database.listMemberStatuses().single() + assertFalse(status.isActive) + + assertTrue(database.updateMembershipPeriod(currentId, rejoined, null, "Reopened")) + assertTrue(database.listMemberStatuses().single().isActive) + assertForeignKeysValid() + } + + @Test + fun manualMembershipMutationRejectsOverlappingRanges() { + val joined = MembershipBoundaryValue( + LocalDate.of(2026, 1, 1), + Instant.parse("2026-01-01T00:00:00Z"), + timeKnown = true, + ) + val withdrew = MembershipBoundaryValue( + LocalDate.of(2026, 3, 1), + Instant.parse("2026-03-01T00:00:00Z"), + timeKnown = true, + ) + assertTrue(database.addWithdrawnMember(TARGET_UID, "Target", joined, withdrew, "")) + + val overlapStart = MembershipBoundaryValue( + LocalDate.of(2026, 2, 1), + Instant.parse("2026-02-01T00:00:00Z"), + timeKnown = true, + ) + val overlapEnd = MembershipBoundaryValue( + LocalDate.of(2026, 4, 1), + Instant.parse("2026-04-01T00:00:00Z"), + timeKnown = true, + ) + assertFalse(database.addMembershipPeriod(TARGET_UID, overlapStart, overlapEnd, "Overlap")) + assertEquals(1, database.listMemberStatuses().single().membershipPeriods.size) + assertForeignKeysValid() + } + private fun createLegacyActivityDatabase(databaseName: String, version: Int) { context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null).use { legacy -> listOf( diff --git a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt index 4ba9463..0f1c74d 100644 --- a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt @@ -46,9 +46,11 @@ class UserSettingsPreferencesIntegrationTest { assertEquals("com.example.gf2", settings.targetPackage) assertFalse(settings.detailedNotifications) assertEquals("system", settings.themeMode) + assertEquals(GameServerRegion.MANUAL.storedValue, settings.gameServerRegion) + assertEquals(java.time.ZoneId.systemDefault().id, settings.gameTimeZoneId) assertTrue(settings.onboardingCompleted) assertEquals( - 2, + 4, context.getSharedPreferences("user_settings", Context.MODE_PRIVATE) .getInt("schema_version", 0), ) diff --git a/app/src/main/cpp/gf2capture.c b/app/src/main/cpp/gf2capture.c index 81f4fa2..20e6534 100644 --- a/app/src/main/cpp/gf2capture.c +++ b/app/src/main/cpp/gf2capture.c @@ -42,6 +42,7 @@ typedef struct capture_context { jobject listener; jmethodID protect_method; jmethodID payload_method; + jmethodID flow_opened_method; jmethodID flow_closed_method; jmethodID traffic_method; jmethodID stopped_method; @@ -257,6 +258,32 @@ static int connection_open_callback(zdtun_t *tunnel, zdtun_conn_t *connection) { flow->id = ++context->next_flow_id; flow->inspect_payload = zdtun_conn_get_5tuple(connection)->ipproto == IPPROTO_TCP; zdtun_conn_set_userdata(connection, flow); + + const zdtun_5tuple_t *tuple = zdtun_conn_get_5tuple(connection); + const int family = tuple->ipver == 4 ? AF_INET : AF_INET6; + char local_address[INET6_ADDRSTRLEN] = {0}; + char remote_address[INET6_ADDRSTRLEN] = {0}; + if (inet_ntop(family, &tuple->src_ip, local_address, sizeof(local_address)) != NULL && + inet_ntop(family, &tuple->dst_ip, remote_address, sizeof(remote_address)) != NULL) { + jstring local = (*context->env)->NewStringUTF(context->env, local_address); + jstring remote = (*context->env)->NewStringUTF(context->env, remote_address); + if (local != NULL && remote != NULL && + !clear_java_exception(context, "flow address allocation")) { + (*context->env)->CallVoidMethod( + context->env, + context->listener, + context->flow_opened_method, + (jlong) flow->id, + (jint) tuple->ipproto, + local, + (jint) ntohs(tuple->src_port), + remote, + (jint) ntohs(tuple->dst_port)); + clear_java_exception(context, "flow open"); + } + if (local != NULL) (*context->env)->DeleteLocalRef(context->env, local); + if (remote != NULL) (*context->env)->DeleteLocalRef(context->env, remote); + } return 0; } @@ -502,6 +529,11 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( jclass listener_class = (*env)->GetObjectClass(env, listener); context->protect_method = (*env)->GetMethodID(env, service_class, "protect", "(I)Z"); context->payload_method = (*env)->GetMethodID(env, listener_class, "onPayload", "(JZ[B)V"); + context->flow_opened_method = (*env)->GetMethodID( + env, + listener_class, + "onFlowOpened", + "(JILjava/lang/String;ILjava/lang/String;I)V"); context->flow_closed_method = (*env)->GetMethodID(env, listener_class, "onFlowClosed", "(J)V"); context->traffic_method = (*env)->GetMethodID(env, listener_class, "onTraffic", "(JJJ)V"); context->stopped_method = (*env)->GetMethodID(env, listener_class, "onCaptureStopped", "()V"); @@ -509,6 +541,7 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( (*env)->DeleteLocalRef(env, listener_class); if (context->protect_method == NULL || context->payload_method == NULL || + context->flow_opened_method == NULL || context->flow_closed_method == NULL || context->traffic_method == NULL || context->stopped_method == NULL || (*env)->ExceptionCheck(env)) { diff --git a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt index 22c11c9..00662f6 100644 --- a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt +++ b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt @@ -33,8 +33,8 @@ internal class DateTimePickerInput( initialTimeKnown: Boolean = initialValue != null, private val dateRequired: Boolean = false, editable: Boolean = true, + private val zone: ZoneId = ZoneId.systemDefault(), ) : LinearLayout(context) { - private val zone: ZoneId = ZoneId.systemDefault() private val selection = MembershipBoundaryDraft( initialValue = initialValue, initialDate = initialDate, diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 1de2f19..cb1ba29 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -468,7 +468,7 @@ class MainActivity : LocalizedActivity() { // Function Name: renderGuidedCaptureProgress // Description: - // - Renders the three useful Platoon payloads as a live checklist. + // - Renders the four required Platoon payloads as a live checklist. // - Keeps display logic independent from capture-service status messages. // Parameters: // - None. @@ -482,6 +482,7 @@ class MainActivity : LocalizedActivity() { if (payloadType in progress.capturedPayloadTypes) "\u2713" else "\u25cb" guidedCaptureText.text = getString( R.string.guided_capture_checklist, + mark(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE), mark(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS), mark(Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY), mark(Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES), diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 83c7990..49a7d2e 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -15,8 +15,8 @@ import dev.gf2log.app.management.MembershipPeriod import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.isImmutableMembershipBoundary import dev.gf2log.app.management.isValidMembershipRange +import dev.gf2log.app.settings.GameTimeZonePreferences import java.time.Instant -import java.time.ZoneId import java.time.format.DateTimeFormatter class MemberDetailActivity : LocalizedActivity() { @@ -189,6 +189,7 @@ class MemberDetailActivity : LocalizedActivity() { initialTimeKnown = membershipPeriod.joinedTimeKnown, dateRequired = true, editable = !membershipPeriod.joinedSource.isImmutableMembershipBoundary(), + zone = GameTimeZonePreferences.get(this), ) val left = DateTimePickerInput( context = this, @@ -197,6 +198,7 @@ class MemberDetailActivity : LocalizedActivity() { initialDate = membershipPeriod.leftDate, initialTimeKnown = membershipPeriod.leftTimeKnown ?: (membershipPeriod.leftAt != null), editable = membershipPeriod.leftSource?.isImmutableMembershipBoundary() != true, + zone = GameTimeZonePreferences.get(this), ) val note = EditText(this).apply { hint = getString(R.string.membership_note_hint) @@ -302,8 +304,13 @@ class MemberDetailActivity : LocalizedActivity() { this, getString(R.string.join_field), dateRequired = true, + zone = GameTimeZonePreferences.get(this), + ) + val withdrew = DateTimePickerInput( + this, + getString(R.string.withdraw_field), + zone = GameTimeZonePreferences.get(this), ) - val withdrew = DateTimePickerInput(this, getString(R.string.withdraw_field)) val note = EditText(this).apply { hint = getString(R.string.membership_note_hint) minLines = 2 @@ -351,10 +358,11 @@ class MemberDetailActivity : LocalizedActivity() { instant: Instant?, timeKnown: Boolean, ): String { - val displayDate = date ?: instant?.atZone(ZoneId.systemDefault())?.toLocalDate() + val zone = GameTimeZonePreferences.get(this) + val displayDate = date ?: instant?.atZone(zone)?.toLocalDate() ?: return getString(R.string.unknown) return if (timeKnown && instant != null) { - instant.atZone(ZoneId.systemDefault()).format(DISPLAY_TIME) + instant.atZone(zone).format(DISPLAY_TIME) } else { displayDate.format(DISPLAY_DATE) } diff --git a/app/src/main/java/dev/gf2log/app/ModernUi.kt b/app/src/main/java/dev/gf2log/app/ModernUi.kt index 438d0cf..4012bc7 100644 --- a/app/src/main/java/dev/gf2log/app/ModernUi.kt +++ b/app/src/main/java/dev/gf2log/app/ModernUi.kt @@ -213,12 +213,12 @@ object ModernUi { ), ) val railInset = context.dp(4) - val segmentHeight = context.dp(48) + val segmentHeight = context.dp(40) val selection = View(context).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO background = GradientDrawable().apply { setColor(context.getColor(R.color.accent_surface)) - cornerRadius = context.dp(12).toFloat() + cornerRadius = context.dp(10).toFloat() } } val choices = RadioGroup(context).apply { @@ -226,11 +226,11 @@ object ModernUi { background = null } val rail = FrameLayout(context).apply { - minimumHeight = context.dp(56) + minimumHeight = context.dp(48) setPadding(railInset, railInset, railInset, railInset) background = GradientDrawable().apply { setColor(context.getColor(R.color.surface_variant)) - cornerRadius = context.dp(16).toFloat() + cornerRadius = context.dp(14).toFloat() } addView(selection, FrameLayout.LayoutParams(0, segmentHeight)) addView( @@ -288,7 +288,7 @@ object ModernUi { context, android.R.color.white, android.R.color.transparent, - 12, + 10, ), ) setTextColor(selectedTextColors) diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 2c36589..c6da9bb 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.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.database.Cursor @@ -24,11 +25,14 @@ import android.widget.TextView import android.widget.Toast import dev.gf2log.app.settings.PayloadHistoryPreferences import dev.gf2log.app.settings.CapturePreferences +import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.capture.CaptureDiagnosticsStore import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.management.BackupFileName import dev.gf2log.app.management.InvalidBackupException import dev.gf2log.app.management.PlatoonBackupManager +import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.discord.DiscordWebhookSecretStore import dev.gf2log.protocol.Gfl2PayloadDecoder import java.time.ZoneId @@ -130,7 +134,6 @@ class OptionsActivity : LocalizedActivity() { selectedValue = LanguagePreferences.get(context), onSelected = ::changeLanguage, ), matchWidth()) - addView(TextView(context).apply { text = getString(R.string.appearance) textSize = 15f @@ -154,6 +157,40 @@ class OptionsActivity : LocalizedActivity() { onSelected = ::changeTheme, ), matchWidth()) + addView(TextView(context).apply { + text = getString(R.string.daily_reset_time) + textSize = 15f + setTypeface(typeface, Typeface.BOLD) + setPadding(0, spacing, 0, dp(2)) + }, matchWidth()) + addView(TextView(context).apply { + text = getString(R.string.settings_daily_reset_detail) + textSize = 12f + setTextColor(getColor(R.color.text_secondary)) + setPadding(0, 0, 0, dp(6)) + }, matchWidth()) + val resetRegion = GameTimeZonePreferences.region(context) + addView(ModernUi.listRow( + context = context, + title = getString(R.string.server_region), + detail = resetSummary(resetRegion), + icon = R.drawable.ic_calendar, + onClick = ::chooseGameServerRegion, + ), matchWidth()) + if (resetRegion == GameServerRegion.MANUAL) { + addView(ModernUi.listRow( + context = context, + title = getString(R.string.game_timezone), + detail = getString( + R.string.manual_timezone_summary, + GameTimeZonePreferences.get(context).id, + GameTimeZonePreferences.deviceZone().id, + ), + icon = R.drawable.ic_calendar, + onClick = ::chooseGameTimeZone, + ), matchWidth()) + } + addView(TextView(context).apply { text = getString(R.string.backup) textSize = 15f @@ -441,8 +478,116 @@ class OptionsActivity : LocalizedActivity() { recreate() } + private fun chooseGameTimeZone() { + val zones = ZoneId.getAvailableZoneIds().sorted() + val current = GameTimeZonePreferences.get(this).id + AlertDialog.Builder(this) + .setTitle(R.string.game_timezone) + .setSingleChoiceItems(zones.toTypedArray(), zones.indexOf(current)) { dialog, which -> + val selected = ZoneId.of(zones[which]) + if (selected == GameTimeZonePreferences.get(this)) { + dialog.dismiss() + return@setSingleChoiceItems + } + dialog.dismiss() + fileIoExecutor.execute { + val result = runCatching { + GameTimeZonePreferences.set(this, selected) + PlatoonRepository(this).rebuildWeeklyHistoryForTimeZoneChange() + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + Toast.makeText( + this, + if (result.isSuccess) { + R.string.game_timezone_updated + } else { + R.string.game_timezone_update_failed + }, + Toast.LENGTH_SHORT, + ).show() + recreate() + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun chooseGameServerRegion() { + val regions = GameServerRegion.entries + val current = GameTimeZonePreferences.region(this) + AlertDialog.Builder(this) + .setTitle(R.string.server_region) + .setSingleChoiceItems( + regions.map(::regionLabel).toTypedArray(), + regions.indexOf(current), + ) { dialog, which -> + val selected = regions[which] + dialog.dismiss() + if (selected == GameServerRegion.MANUAL) { + chooseGameTimeZone() + } else if (selected != current) { + updateResetRegion(selected) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun updateResetRegion(region: GameServerRegion) { + fileIoExecutor.execute { + val result = runCatching { + GameTimeZonePreferences.setRegion(this, region) + PlatoonRepository(this).rebuildWeeklyHistoryForTimeZoneChange() + } + runOnUiThread { + if (isFinishing || isDestroyed) return@runOnUiThread + Toast.makeText( + this, + if (result.isSuccess) R.string.game_timezone_updated + else R.string.game_timezone_update_failed, + Toast.LENGTH_SHORT, + ).show() + recreate() + } + } + } + + private fun resetSummary(region: GameServerRegion): String { + if (region == GameServerRegion.MANUAL) { + return getString( + R.string.manual_reset_region_summary, + GameTimeZonePreferences.get(this).id, + GameTimeZonePreferences.deviceZone().id, + ) + } + val localReset = region.nextReset() + .atZone(GameTimeZonePreferences.deviceZone()) + .format(RESET_LOCAL_TIME) + return getString( + R.string.server_reset_summary, + regionLabel(region), + localReset, + GameTimeZonePreferences.deviceZone().id, + ) + } + + private fun regionLabel(region: GameServerRegion): String = getString( + when (region) { + GameServerRegion.MANUAL -> R.string.server_region_manual + GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global + GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china + GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global + GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan + GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea + GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia + }, + ) + private fun payloadName(payloadType: Int): String = getString( when (payloadType) { + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> R.string.payload_name_platoon_profile Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> R.string.payload_name_platoon_members Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> R.string.payload_name_platoon_activity Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> R.string.payload_name_platoon_updates @@ -456,6 +601,8 @@ class OptionsActivity : LocalizedActivity() { private fun payloadDescription(payloadType: Int): String = getString( when (payloadType) { + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> + R.string.payload_description_platoon_profile Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> R.string.payload_description_platoon_members Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> R.string.payload_description_platoon_activity @@ -478,6 +625,7 @@ class OptionsActivity : LocalizedActivity() { companion object { private val DIAGNOSTIC_TIME = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") + private val RESET_LOCAL_TIME = DateTimeFormatter.ofPattern("HH:mm") private val BACKUP_TIME = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") private val BACKUP_MIME_TYPES = arrayOf( PlatoonBackupManager.MIME_TYPE, diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index 7bd7229..f29de53 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -23,7 +23,7 @@ import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonMemberCsv import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.management.isValidMembershipRange -import java.time.ZoneId +import dev.gf2log.app.settings.GameTimeZonePreferences import java.time.format.DateTimeFormatter import java.util.concurrent.Executors @@ -197,7 +197,7 @@ class PlatoonActivity : LocalizedActivity() { summary.text = if (latest == null) { getString(R.string.no_platoon_snapshot) } else { - val zone = ZoneId.systemDefault() + val zone = GameTimeZonePreferences.get(this@PlatoonActivity) getString( R.string.platoon_summary, active, @@ -323,7 +323,7 @@ class PlatoonActivity : LocalizedActivity() { pendingMemberCsv = PlatoonMemberCsv.format( statuses = selected, latestMembers = latestMembers, - zoneId = ZoneId.systemDefault(), + zoneId = GameTimeZonePreferences.get(this), ) val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) @@ -346,11 +346,13 @@ class PlatoonActivity : LocalizedActivity() { this, getString(R.string.join_field), dateRequired = true, + zone = GameTimeZonePreferences.get(this), ) val withdrew = DateTimePickerInput( this, getString(R.string.withdraw_field), dateRequired = true, + zone = GameTimeZonePreferences.get(this), ) val noteInput = EditText(this).apply { hint = getString(R.string.membership_note_hint) diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index 6abdeb2..901c2c1 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -13,7 +13,7 @@ import android.widget.Toast import dev.gf2log.app.management.SnapshotComparison import dev.gf2log.app.management.SnapshotComparisonCsv import dev.gf2log.app.management.PlatoonRepository -import java.time.ZoneId +import dev.gf2log.app.settings.GameTimeZonePreferences import java.time.format.DateTimeFormatter class SnapshotComparisonActivity : LocalizedActivity() { @@ -29,7 +29,7 @@ class SnapshotComparisonActivity : LocalizedActivity() { addView(text(getString(R.string.need_two_snapshots))) } else { val result = SnapshotComparison.compare(snapshots[1], snapshots[0]) - val zone = ZoneId.systemDefault() + val zone = GameTimeZonePreferences.get(this@SnapshotComparisonActivity) addView( text( getString( diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 242cf60..30d471c 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -50,6 +50,7 @@ import dev.gf2log.app.management.WeeklyReportStateHolder import dev.gf2log.app.management.WeeklyShareProjection import dev.gf2log.app.management.WeeklyMetricPresentation import dev.gf2log.app.settings.MemberOrderPreferences +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines import java.time.Instant @@ -127,7 +128,7 @@ class WeeklyReportActivity : LocalizedActivity() { savedInstanceState?.takeIf { it.containsKey(STATE_REFERENCE_DAY) } ?.getLong(STATE_REFERENCE_DAY) ?.let(LocalDate::ofEpochDay) - ?: PlatoonPeriods.gameDay(Instant.now(), ZoneId.systemDefault()), + ?: PlatoonPeriods.gameDay(Instant.now(), GameTimeZonePreferences.get(this)), ) body = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -201,30 +202,19 @@ class WeeklyReportActivity : LocalizedActivity() { // Returns: // - An immutable render model for the requested week. private fun loadRenderModel(targetDay: LocalDate): RenderModel { - val zone = ZoneId.systemDefault() + val zone = GameTimeZonePreferences.get(this) val periodStart = PlatoonPeriods.weekStart(targetDay) - val membershipStartInstant = periodStart.atStartOfDay(zone).toInstant() - val membershipEndInstant = periodStart.plusDays(7).atStartOfDay(zone).toInstant() - val report = repository.buildWeeklyReport(targetDay, zone) - val memberStatuses = repository.listMemberStatuses() + val revision = repository.buildWeeklyTableRevision(targetDay, zone) + val report = revision.report val history = repository.listWeeklyReportHistory(report.periodStart) return RenderModel( zone = zone, report = report, - notes = repository.listWeeklyNotes(report.periodStart.toEpochDay()) - .filterNot(WeeklyNote::isAutomatic), - events = repository.listEvents( - membershipStartInstant, - membershipEndInstant, - periodStart, - periodStart.plusDays(7), - ).filter { - it.type in MembershipEventPresentation.displayedTypes && - it.source in MembershipEventPresentation.displayedSources - }, - namesByUid = memberStatuses.associate { it.uid to it.name }, + notes = revision.notes, + events = revision.membershipEvents, + namesByUid = revision.memberNamesByUid + report.members.associate { it.uid to it.name }, cutlines = WeeklyCutlinePreferences(this).read(), - memberNotesByUid = memberStatuses.associate { it.uid to it.note }, + memberNotesByUid = revision.memberPrivateNotesByUid, displayedMembers = MemberOrderPreferences(this).apply(report.members) { it.uid }, scoreRanks = report.members.withIndex().associate { it.value.uid to it.index + 1 }, historyCount = history.size, @@ -1633,7 +1623,7 @@ class WeeklyReportActivity : LocalizedActivity() { private fun exportAllWeeklyTables() { workerExecutor.execute { val content = runCatching { - repository.listAllWeeklyReports(ZoneId.systemDefault()) + repository.listAllWeeklyReports(GameTimeZonePreferences.get(this)) .takeIf(List<*>::isNotEmpty) ?.let(WeeklyReportCsv::formatAll) }.getOrNull() diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt index f7df62a..edc2907 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt @@ -12,8 +12,8 @@ import android.widget.ScrollView import android.widget.TextView import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.WeeklyReportHistoryEntry +import dev.gf2log.app.settings.GameTimeZonePreferences import java.time.LocalDate -import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.concurrent.Executors @@ -107,7 +107,7 @@ class WeeklyTableHistoryActivity : LocalizedActivity() { } val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) .withLocale(resources.configuration.locales[0]) - .withZone(ZoneId.systemDefault()) + .withZone(GameTimeZonePreferences.get(this)) rows.forEachIndexed { index, row -> body.addView( ModernUi.actionRow( diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt new file mode 100644 index 0000000..6f6299f --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt @@ -0,0 +1,43 @@ +package dev.gf2log.app.capture + +import android.content.Context +import android.net.ConnectivityManager +import android.os.Build +import dev.gf2log.app.SupportedGamePackages +import java.net.InetAddress +import java.net.InetSocketAddress + +internal data class CaptureFlowMetadata( + val protocol: Int, + val localAddress: String, + val localPort: Int, + val remoteAddress: String, + val remotePort: Int, + val ownerPackage: String?, +) + +/** Resolves an original VPN flow to a supported Android package without endpoint guessing. */ +internal object CaptureFlowOwnerResolver { + fun resolve( + context: Context, + protocol: Int, + localAddress: String, + localPort: Int, + remoteAddress: String, + remotePort: Int, + ): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null + val connectivity = context.getSystemService(ConnectivityManager::class.java) + val uid = runCatching { + connectivity.getConnectionOwnerUid( + protocol, + InetSocketAddress(InetAddress.getByName(localAddress), localPort), + InetSocketAddress(InetAddress.getByName(remoteAddress), remotePort), + ) + }.getOrNull() ?: return null + if (uid < 0) return null + return context.packageManager.getPackagesForUid(uid) + .orEmpty() + .firstOrNull { it in SupportedGamePackages.all } + } +} 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 8a16bb9..006d47a 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -22,6 +22,7 @@ import dev.gf2log.protocol.Gfl2StreamParser import dev.gf2log.protocol.Gfl2PayloadDecoder import dev.gf2log.protocol.PayloadCatalog import dev.gf2log.protocol.model.ParseEvent +import dev.gf2log.protocol.model.PlatoonProfileData import java.io.File import java.time.Instant import java.util.concurrent.ArrayBlockingQueue @@ -36,6 +37,7 @@ class CaptureVpnService : VpnService() { private var tunnel: ParcelFileDescriptor? = null private val parsers = ConcurrentHashMap() private val taintedFlows = ConcurrentHashMap.newKeySet() + private val flowMetadata = ConcurrentHashMap() private val decodedPayloadCount = AtomicLong() private val observedPayloadBytes = AtomicLong() private val inspectedPayloadBytes = AtomicLong() @@ -231,6 +233,7 @@ class CaptureVpnService : VpnService() { } parsers.clear() taintedFlows.clear() + flowMetadata.clear() decodedPayloadCount.set(0) observedPayloadBytes.set(0) inspectedPayloadBytes.set(0) @@ -248,6 +251,24 @@ class CaptureVpnService : VpnService() { descriptor.fd, this, object : NativeCaptureBridge.PayloadListener { + override fun onFlowOpened( + flowId: Long, + protocol: Int, + localAddress: String, + localPort: Int, + remoteAddress: String, + remotePort: Int, + ) { + enqueueFlowOpened( + flowId, + protocol, + localAddress, + localPort, + remoteAddress, + remotePort, + ) + } + override fun onPayload(flowId: Long, isSent: Boolean, payload: ByteArray) { enqueuePayload(flowId, isSent, payload) } @@ -288,7 +309,7 @@ class CaptureVpnService : VpnService() { if (!submitParserTask { if (flowId in taintedFlows) return@submitParserTask val parser = parsers.computeIfAbsent(flowId) { Gfl2StreamParser() } - processEvents(parser.accept(payload)) + processEvents(flowId, parser.accept(payload)) }) { // A missing TCP chunk makes every later byte offset unreliable. // Keep this flow quarantined until native closure instead of @@ -297,14 +318,43 @@ class CaptureVpnService : VpnService() { } } + private fun enqueueFlowOpened( + flowId: Long, + protocol: Int, + localAddress: String, + localPort: Int, + remoteAddress: String, + remotePort: Int, + ) { + submitParserTask { + flowMetadata[flowId] = CaptureFlowMetadata( + protocol = protocol, + localAddress = localAddress, + localPort = localPort, + remoteAddress = remoteAddress, + remotePort = remotePort, + ownerPackage = CaptureFlowOwnerResolver.resolve( + this, + protocol, + localAddress, + localPort, + remoteAddress, + remotePort, + ), + ) + } + } + private fun enqueueFlowClosed(flowId: Long) { if (!submitParserTask { if (taintedFlows.remove(flowId)) { parsers.remove(flowId) + flowMetadata.remove(flowId) return@submitParserTask } val parser = parsers.remove(flowId) ?: return@submitParserTask - processEvents(parser.finish(), flowEnded = true) + processEvents(flowId, parser.finish(), flowEnded = true) + flowMetadata.remove(flowId) } ) { parsers.remove(flowId) @@ -325,7 +375,11 @@ class CaptureVpnService : VpnService() { false } - private fun processEvents(events: List, flowEnded: Boolean = false) { + private fun processEvents( + flowId: Long, + events: List, + flowEnded: Boolean = false, + ) { val warnings = events.filterIsInstance() if (warnings.isNotEmpty()) parseWarningCount.addAndGet(warnings.size.toLong()) @@ -334,6 +388,19 @@ class CaptureVpnService : VpnService() { unknownPayloadCounts.computeIfAbsent(event.payloadType) { AtomicLong() }.incrementAndGet() } decoded.forEach { event -> + if (event.value.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { + val profile = event.value.data as? PlatoonProfileData + if (profile != null && profile.platoonId != 0u && profile.platoonName.isNotBlank()) { + val client = when (flowMetadata[flowId]?.ownerPackage) { + SupportedGamePackages.HAOPLAY -> "HaoPlay" + SupportedGamePackages.DARKWINTER -> "Darkwinter" + else -> "unknown client" + } + val name = profile.platoonName.replace(Regex("\\s+"), " ").take(40) + CaptureStatus.update("Detected $name (${profile.platoonId}) via $client") + } + markRequiredPayloadCaptured(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) + } if (payloadHistoryPreferences.isEnabled(event.value.payloadType)) { runCatching { historyStore.save(event.value) } .onFailure { CaptureStatus.update("Unable to save parsed-packet history") } @@ -411,6 +478,7 @@ class CaptureVpnService : VpnService() { tunnel = null drainParserTasks() parsers.clear() + flowMetadata.clear() CaptureStatus.markStopped("Capture stopped unexpectedly; press Prepare capture to retry") stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() @@ -440,6 +508,7 @@ class CaptureVpnService : VpnService() { drainParserTasks() parsers.clear() taintedFlows.clear() + flowMetadata.clear() saveDiagnostics() } @@ -548,6 +617,7 @@ class CaptureVpnService : VpnService() { private const val TRAFFIC_REPORT_BYTES = 64 * 1024 private const val CAPTURE_ONCE_GRACE_MILLIS = 60_000L private val REQUIRED_CAPTURE_TYPES = setOf( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, diff --git a/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt b/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt index 7257606..4c114af 100644 --- a/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt +++ b/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt @@ -4,6 +4,15 @@ import android.net.VpnService object NativeCaptureBridge { interface PayloadListener { + fun onFlowOpened( + flowId: Long, + protocol: Int, + localAddress: String, + localPort: Int, + remoteAddress: String, + remotePort: Int, + ) + fun onPayload(flowId: Long, isSent: Boolean, payload: ByteArray) fun onFlowClosed(flowId: Long) diff --git a/app/src/main/java/dev/gf2log/app/management/ManagementConsistencyAudit.kt b/app/src/main/java/dev/gf2log/app/management/ManagementConsistencyAudit.kt new file mode 100644 index 0000000..7791898 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/ManagementConsistencyAudit.kt @@ -0,0 +1,37 @@ +package dev.gf2log.app.management + +/** Deterministic post-condition checks for user-visible management projections. */ +internal object ManagementConsistencyAudit { + fun requireValid(revision: WeeklyTableRevision): WeeklyTableRevision = revision.also { + val report = it.report + require(report.periodEnd == report.periodStart.plusDays(6)) { + "Weekly report period is not seven calendar days" + } + val expectedDays = List(7) { index -> report.periodStart.plusDays(index.toLong()) } + require(report.days == expectedDays) { "Weekly report days are not contiguous" } + require(report.members.map { member -> member.uid }.distinct().size == report.members.size) { + "Weekly report contains duplicate member identities" + } + report.members.forEach { member -> + require(member.uid > 0L) { "Weekly report contains an invalid member identity" } + require(member.days.map { cell -> cell.gameDay } == expectedDays) { + "Weekly member cells do not match the report period" + } + require(member.days.all { cell -> cell.isGunsmokeWeek == report.isGunsmokeWeek }) { + "Weekly member cells disagree with the report mode" + } + } + require(it.notes.all { note -> + note.periodStart == report.periodStart && note.gameDay in expectedDays + }) { "Weekly note lies outside the report period" } + require(it.membershipEvents.all { event -> event.uid > 0L }) { + "Weekly membership event contains an invalid member identity" + } + require(it.memberNamesByUid.keys.all { uid -> uid > 0L }) { + "Weekly name context contains an invalid member identity" + } + require(it.memberPrivateNotesByUid.keys.all { uid -> + report.members.any { member -> member.uid == uid } + }) { "Weekly private-note context contains an unrelated member" } + } +} diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt new file mode 100644 index 0000000..ba0b204 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt @@ -0,0 +1,80 @@ +package dev.gf2log.app.management + +import java.time.Instant +import java.time.LocalDate + +/** One persisted membership interval used by the deterministic consistency audit. */ +internal data class MembershipInterval( + val id: Long, + val joinedAt: Instant?, + val leftAt: Instant?, + val joinedDate: LocalDate? = null, + val leftDate: LocalDate? = null, + val joinedTimeKnown: Boolean = true, + val leftTimeKnown: Boolean = true, +) + +/** Enforces chronology rules shared by manual edits and post-mutation verification. */ +internal object MembershipConsistencyPolicy { + fun violation(periods: List): String? { + periods.forEach { period -> + if (!hasValidOwnChronology(period)) { + return "Membership period ${period.id} does not end after it starts" + } + } + if (periods.count { it.leftAt == null } > 1) { + return "A member cannot have more than one open membership period" + } + // An absent start is deliberately unknown, not negative infinity. Reject only + // overlaps proven by two known starts; uncertain legacy/snapshot boundaries stay editable. + val ordered = periods.filter { it.joinedAt != null }.sortedWith( + Comparator(::compareStarts), + ) + ordered.zipWithNext().forEach { (earlier, later) -> + if (periodsProvablyOverlap(earlier, later)) { + return "Membership periods ${earlier.id} and ${later.id} overlap" + } + } + return null + } + + fun isActive(periods: List): Boolean = periods.any { it.leftAt == null } + + private fun hasValidOwnChronology(period: MembershipInterval): Boolean { + val joinedAt = period.joinedAt ?: return true + val leftAt = period.leftAt ?: return true + val joinedDate = period.joinedDate + val leftDate = period.leftDate + if (joinedDate != null && leftDate != null) { + val dateOrder = leftDate.compareTo(joinedDate) + if (dateOrder != 0) return dateOrder > 0 + if (!period.joinedTimeKnown || !period.leftTimeKnown) return true + } + return !leftAt.isBefore(joinedAt) + } + + private fun compareStarts(first: MembershipInterval, second: MembershipInterval): Int { + val dateOrder = if (first.joinedDate != null && second.joinedDate != null) { + first.joinedDate.compareTo(second.joinedDate) + } else { + requireNotNull(first.joinedAt).compareTo(requireNotNull(second.joinedAt)) + } + return if (dateOrder != 0) dateOrder else first.id.compareTo(second.id) + } + + private fun periodsProvablyOverlap( + earlier: MembershipInterval, + later: MembershipInterval, + ): Boolean { + val earlierEnd = earlier.leftAt ?: return true + val laterStart = requireNotNull(later.joinedAt) + val earlierEndDate = earlier.leftDate + val laterStartDate = later.joinedDate + if (earlierEndDate != null && laterStartDate != null) { + val dateOrder = earlierEndDate.compareTo(laterStartDate) + if (dateOrder != 0) return dateOrder > 0 + if (!earlier.leftTimeKnown || !later.joinedTimeKnown) return false + } + return earlierEnd.isAfter(laterStart) + } +} 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 876ea9c..74affa7 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -5,6 +5,7 @@ import android.content.Context import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.protocol.GuildMembersCsv import java.time.Instant import java.time.LocalDate @@ -21,6 +22,7 @@ class PlatoonDatabase( null, PlatoonSchema.CURRENT_VERSION, ) { + private val appContext = context.applicationContext override fun onConfigure(db: SQLiteDatabase) { super.onConfigure(db) @@ -230,13 +232,14 @@ class PlatoonDatabase( backfillSnapshotMembershipPeriodEvents(db) } if (needsManualCalendarDateBackfill) { - backfillManualCalendarDates(db, ZoneId.systemDefault()) + backfillManualCalendarDates(db, GameTimeZonePreferences.get(appContext)) } if (oldVersion < 11) { createPlatoonMaintenanceStateTable(db) createPlatoonActivityRetentionIndex(db) } if (oldVersion < 12) createWeeklyReportHistoryTables(db) + if (oldVersion < 13) synchronizeAllMembershipStates(db) } private fun createWeeklyReportHistoryTables(db: SQLiteDatabase) { @@ -1901,6 +1904,7 @@ class PlatoonDatabase( ) val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, name.trim(), joined, withdrew) + synchronizeAndVerifyMembershipState(db, uid) db.setTransactionSuccessful() return true } finally { @@ -1920,8 +1924,10 @@ class PlatoonDatabase( db.beginTransaction() try { val memberName = memberName(db, uid) ?: return false + if (!canStoreMembershipPeriod(db, uid, null, joined, withdrew)) return false val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, memberName, joined, withdrew) + synchronizeAndVerifyMembershipState(db, uid) db.setTransactionSuccessful() return true } finally { @@ -2125,16 +2131,7 @@ class PlatoonDatabase( ) { return false } - val isActive = db.rawQuery( - "SELECT 1 FROM membership_periods WHERE uid = ? AND left_at IS NULL LIMIT 1", - arrayOf(uid.toString()), - ).use(Cursor::moveToFirst) - db.update( - "members", - ContentValues().apply { put("is_active", if (isActive) 1 else 0) }, - "uid = ?", - arrayOf(uid.toString()), - ) + synchronizeAndVerifyMembershipState(db, uid) db.setTransactionSuccessful() return true } finally { @@ -2164,6 +2161,17 @@ class PlatoonDatabase( left } require(isValidMembershipRange(effectiveJoined, effectiveLeft)) + if ( + !canStoreMembershipPeriod( + db, + membershipPeriod.uid, + membershipPeriodId, + effectiveJoined, + effectiveLeft, + ) + ) { + return false + } val joinedChanged = editableMembershipBoundaryChanged( original = membershipPeriod.joined, requested = joined, @@ -2222,6 +2230,7 @@ class PlatoonDatabase( }, ) } + if (updated) synchronizeAndVerifyMembershipState(db, membershipPeriod.uid) db.setTransactionSuccessful() return updated } finally { @@ -2229,6 +2238,99 @@ class PlatoonDatabase( } } + // Function Name: canStoreMembershipPeriod + // Description: + // - Evaluates a proposed manual period together with every persisted period for the member. + // - Rejects overlapping ranges and multiple open periods before any database row is changed. + private fun canStoreMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + replacedPeriodId: Long?, + joined: MembershipBoundaryValue, + left: MembershipBoundaryValue?, + ): Boolean { + val proposed = MembershipInterval( + id = replacedPeriodId ?: Long.MAX_VALUE, + joinedAt = joined.instant, + leftAt = left?.instant, + joinedDate = joined.date, + leftDate = left?.date, + joinedTimeKnown = joined.timeKnown, + leftTimeKnown = left?.timeKnown ?: true, + ) + val periods = readMembershipIntervals(db, uid) + .filterNot { it.id == replacedPeriodId } + proposed + return MembershipConsistencyPolicy.violation(periods) == null + } + + // Function Name: synchronizeAndVerifyMembershipState + // Description: + // - Derives current member activity from the canonical period timeline. + // - Verifies chronology and event ownership before allowing the surrounding transaction to commit. + private fun synchronizeAndVerifyMembershipState(db: SQLiteDatabase, uid: Long) { + val periods = readMembershipIntervals(db, uid) + checkNotNull(periods.takeIf { it.isNotEmpty() }) { "A member must retain a membership period" } + val violation = MembershipConsistencyPolicy.violation(periods) + check(violation == null) { violation.orEmpty() } + val updated = db.update( + "members", + ContentValues().apply { + put("is_active", if (MembershipConsistencyPolicy.isActive(periods)) 1 else 0) + }, + "uid = ?", + arrayOf(uid.toString()), + ) + check(updated == 1) { "Membership state has no owning member" } + val mismatchedEvent = db.rawQuery( + "SELECT 1 FROM member_events event " + + "JOIN membership_periods period ON period.id = event.membership_period_id " + + "WHERE event.uid != period.uid AND (event.uid = ? OR period.uid = ?) LIMIT 1", + arrayOf(uid.toString(), uid.toString()), + ).use(Cursor::moveToFirst) + check(!mismatchedEvent) { "Membership event belongs to a different member" } + } + + // Function Name: synchronizeAllMembershipStates + // Description: + // - Repairs the derived active flag while upgrading databases created before schema v13. + // - Leaves every evidence period intact and derives only the current-state projection. + private fun synchronizeAllMembershipStates(db: SQLiteDatabase) { + db.execSQL( + """ + UPDATE members + SET is_active = CASE WHEN EXISTS ( + SELECT 1 + FROM membership_periods period + WHERE period.uid = members.uid AND period.left_at IS NULL + ) THEN 1 ELSE 0 END + """.trimIndent(), + ) + } + + private fun readMembershipIntervals(db: SQLiteDatabase, uid: Long): List = + db.rawQuery( + "SELECT id, joined_at, left_at, joined_date, left_date, " + + "joined_time_known, left_time_known " + + "FROM membership_periods WHERE uid = ? ORDER BY id", + arrayOf(uid.toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + MembershipInterval( + id = cursor.getLong(0), + joinedAt = cursor.getNullableLong(1)?.let(Instant::ofEpochMilli), + leftAt = cursor.getNullableLong(2)?.let(Instant::ofEpochMilli), + joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), + leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), + joinedTimeKnown = cursor.getNullableInt(5)?.let { it != 0 } ?: true, + leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 } ?: true, + ), + ) + } + } + } + @Synchronized fun addWeeklyNote(periodStartEpochDay: Long, gameDayEpochDay: Long, text: String): Long { require(text.isNotBlank()) @@ -2272,6 +2374,14 @@ class PlatoonDatabase( } } + @Synchronized + fun weeklyNotePeriodStart(id: Long): LocalDate? = readableDatabase.rawQuery( + "SELECT period_start FROM weekly_notes WHERE id = ? AND is_automatic = 0", + arrayOf(id.toString()), + ).use { cursor -> + if (cursor.moveToFirst()) LocalDate.ofEpochDay(cursor.getLong(0)) else null + } + @Synchronized fun deleteWeeklyNote(id: Long): Boolean = writableDatabase.delete( @@ -2534,6 +2644,19 @@ class PlatoonDatabase( arrayOf(periodStartEpochDay.toString()), ) > 0 + @Synchronized + fun clearWeeklyReportHistory() { + val db = writableDatabase + db.beginTransaction() + try { + db.delete("weekly_report_history_state", null, null) + db.delete("weekly_report_history", null, null) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + private fun createWeeklyOverridesTable(db: SQLiteDatabase) { db.execSQL( """ @@ -3521,11 +3644,13 @@ class PlatoonDatabase( instantIndex = 1, dateIndex = 3, timeKnownIndex = 5, + zoneId = GameTimeZonePreferences.get(appContext), ), left = cursor.membershipBoundaryValue( instantIndex = 2, dateIndex = 4, timeKnownIndex = 6, + zoneId = GameTimeZonePreferences.get(appContext), ), joinedSource = EvidenceSource.valueOf(cursor.getString(7)), leftSource = cursor.getNullableString(8)?.let(EvidenceSource::valueOf), @@ -3959,10 +4084,11 @@ private fun Cursor.membershipBoundaryValue( instantIndex: Int, dateIndex: Int, timeKnownIndex: Int, + zoneId: ZoneId, ): MembershipBoundaryValue? { val instant = getNullableLong(instantIndex)?.let(Instant::ofEpochMilli) ?: return null val date = getNullableLong(dateIndex)?.let(LocalDate::ofEpochDay) - ?: instant.atZone(java.time.ZoneId.systemDefault()).toLocalDate() + ?: instant.atZone(zoneId).toLocalDate() return MembershipBoundaryValue( date = date, instant = instant, diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt index 61af465..cf78876 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt @@ -159,6 +159,15 @@ data class WeeklyNote( val isAutomatic: Boolean, ) +/** Complete immutable state needed to render or share one historical weekly table. */ +data class WeeklyTableRevision( + val report: WeeklyReportBuilder.Report, + val membershipEvents: List, + val notes: List, + val memberNamesByUid: Map, + val memberPrivateNotesByUid: Map, +) + data class WeeklyCellOverride( val uid: Long, val periodStart: LocalDate, 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 dce6608..ad25688 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -1,6 +1,8 @@ package dev.gf2log.app.management import android.content.Context +import android.util.Log +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.model.GuildMember @@ -179,10 +181,12 @@ class PlatoonRepository(context: Context) { access { it.listDailyPatrolFacts(from, until) } fun updateMember(uid: Long, name: String, note: String): Boolean = - access { it.updateMember(uid, name, note) } + access { it.updateMember(uid, name, note) }.also { changed -> + if (changed) recordAllLiveWeeklyReports() + } fun deleteMember(uid: Long): Boolean { - return withExclusiveDatabase { + val deleted = withExclusiveDatabase { val order = MemberOrderPreferences(appContext) val previousOrder = order.read() val updatedOrder = previousOrder.filterNot { it == uid } @@ -208,6 +212,8 @@ class PlatoonRepository(context: Context) { throw error } } + if (deleted) recordAllLiveWeeklyReports() + return deleted } fun updateMembershipPeriod( @@ -216,9 +222,11 @@ class PlatoonRepository(context: Context) { left: MembershipBoundaryValue?, note: String, ): Boolean = access { it.updateMembershipPeriod(membershipPeriodId, joined, left, note) } + .also { changed -> if (changed) recordAllLiveWeeklyReports() } fun deleteMembershipPeriod(membershipPeriodId: Long): Boolean = access { it.deleteMembershipPeriod(membershipPeriodId) } + .also { changed -> if (changed) recordAllLiveWeeklyReports() } fun addWithdrawnMember( uid: Long, @@ -227,6 +235,7 @@ class PlatoonRepository(context: Context) { withdrew: MembershipBoundaryValue, note: String, ): Boolean = access { it.addWithdrawnMember(uid, name, joined, withdrew, note) } + .also { changed -> if (changed) recordAllLiveWeeklyReports() } fun addMembershipPeriod( uid: Long, @@ -234,14 +243,22 @@ class PlatoonRepository(context: Context) { withdrew: MembershipBoundaryValue?, note: String, ): Boolean = access { it.addMembershipPeriod(uid, joined, withdrew, note) } + .also { changed -> if (changed) recordAllLiveWeeklyReports() } fun addWeeklyNote(periodStartEpochDay: Long, gameDayEpochDay: Long, text: String): Long = - access { it.addWeeklyNote(periodStartEpochDay, gameDayEpochDay, text) } + access { it.addWeeklyNote(periodStartEpochDay, gameDayEpochDay, text) }.also { + recordLiveWeeklyRevision(LocalDate.ofEpochDay(periodStartEpochDay)) + } fun listWeeklyNotes(periodStartEpochDay: Long): List = access { it.listWeeklyNotes(periodStartEpochDay) } - fun deleteWeeklyNote(id: Long): Boolean = access { it.deleteWeeklyNote(id) } + fun deleteWeeklyNote(id: Long): Boolean { + val periodStart = access { it.weeklyNotePeriodStart(id) } ?: return false + return access { it.deleteWeeklyNote(id) }.also { changed -> + if (changed) recordLiveWeeklyRevision(periodStart) + } + } fun listWeeklyOverrides(periodStartEpochDay: Long): List = access { it.listWeeklyOverrides(periodStartEpochDay) } @@ -250,21 +267,65 @@ class PlatoonRepository(context: Context) { referenceDay: LocalDate, zoneId: ZoneId, asOf: Instant = Instant.now(), - ): WeeklyReportBuilder.Report { - val live = buildLiveWeeklyReport(referenceDay, zoneId, asOf) + ): WeeklyReportBuilder.Report = buildWeeklyTableRevision(referenceDay, zoneId, asOf).report + + fun buildWeeklyTableRevision( + referenceDay: LocalDate, + zoneId: ZoneId, + asOf: Instant = Instant.now(), + ): WeeklyTableRevision { + val live = buildLiveWeeklyRevision(referenceDay, zoneId, asOf) recordHistory(live, Instant.now(), clearActiveOnChange = false) val activePayload = access { - it.activeWeeklyReportHistoryPayload(live.periodStart.toEpochDay()) + it.activeWeeklyReportHistoryPayload(live.report.periodStart.toEpochDay()) } ?: return live - return runCatching { WeeklyReportHistoryCodec.decode(activePayload) } + return runCatching { WeeklyReportHistoryCodec.decodeRevision(activePayload) } .getOrElse { access { database -> - database.clearActiveWeeklyReportHistory(live.periodStart.toEpochDay()) + database.clearActiveWeeklyReportHistory(live.report.periodStart.toEpochDay()) } live } } + private fun buildLiveWeeklyRevision( + referenceDay: LocalDate, + zoneId: ZoneId, + asOf: Instant, + ): WeeklyTableRevision { + val report = buildLiveWeeklyReport(referenceDay, zoneId, asOf) + val periodStart = report.periodStart + val statuses = listMemberStatuses() + val membershipEvents = listEvents( + periodStart.atStartOfDay(zoneId).toInstant(), + periodStart.plusDays(7).atStartOfDay(zoneId).toInstant(), + periodStart, + periodStart.plusDays(7), + ).filter { + it.type in MembershipEventPresentation.displayedTypes && + it.source in MembershipEventPresentation.displayedSources + }.take(MAX_REVISION_EVENTS) + .map { it.copy(note = it.note.boundedUtf8(MAX_REVISION_NOTE_BYTES)) } + val relevantNameUids = ( + report.members.asSequence().map { it.uid } + membershipEvents.asSequence().map { it.uid } + ).toSet() + return ManagementConsistencyAudit.requireValid(WeeklyTableRevision( + report = report, + membershipEvents = membershipEvents, + notes = listWeeklyNotes(periodStart.toEpochDay()) + .filterNot(WeeklyNote::isAutomatic) + .take(MAX_REVISION_NOTES) + .map { it.copy(text = it.text.boundedUtf8(MAX_REVISION_NOTE_BYTES)) }, + memberNamesByUid = statuses.asSequence() + .filter { it.uid in relevantNameUids } + .take(MAX_REVISION_NAMES) + .associate { it.uid to it.name.boundedUtf8(MAX_REVISION_NAME_BYTES) }, + memberPrivateNotesByUid = statuses.asSequence() + .filter { status -> report.members.any { it.uid == status.uid } } + .associate { it.uid to it.note.boundedUtf8(MAX_REVISION_PRIVATE_NOTE_BYTES) }, + )) + } + private fun buildLiveWeeklyReport( referenceDay: LocalDate, zoneId: ZoneId, @@ -307,12 +368,8 @@ class PlatoonRepository(context: Context) { ) { access { it.replaceWeeklyOverrides(periodStartEpochDay, overrides) } val day = LocalDate.ofEpochDay(periodStartEpochDay) - val zone = ZoneId.systemDefault() - recordHistory( - report = buildLiveWeeklyReport(day, zone, Instant.now()), - recordedAt = Instant.now(), - clearActiveOnChange = true, - ) + val zone = GameTimeZonePreferences.get(appContext) + recordLiveWeeklyRevisionSafely(day, zone) } fun listWeeklyReportHistory(periodStart: LocalDate): List = @@ -328,39 +385,56 @@ class PlatoonRepository(context: Context) { fun showLiveWeeklyReport(periodStart: LocalDate): Boolean = access { it.clearActiveWeeklyReportHistory(periodStart.toEpochDay()) } + fun rebuildWeeklyHistoryForTimeZoneChange() { + withExclusiveDatabase { + PlatoonDatabase(appContext).use(PlatoonDatabase::clearWeeklyReportHistory) + } + recordAllLiveWeeklyReports(failFast = true) + } + private fun recordChangedWeeks(instants: Iterable) = recordChangedWeeks(instants.asSequence()) private fun recordChangedWeeks(instants: Sequence) { - val zone = ZoneId.systemDefault() + val zone = GameTimeZonePreferences.get(appContext) WeeklyHistoryWorkPolicy.changedPeriodStarts(instants, zone) .forEach { periodStart -> - val report = buildLiveWeeklyReport(periodStart, zone, Instant.now()) - recordHistory(report, Instant.now(), clearActiveOnChange = true) + recordLiveWeeklyRevisionSafely(periodStart, zone) } } - private fun recordAllLiveWeeklyReports() { - val zone = ZoneId.systemDefault() + // Function Name: recordAllLiveWeeklyReports + // Description: + // - Refreshes every derived weekly revision after a primary-data mutation. + // - Keeps committed member or packet data authoritative when optional history maintenance fails. + // Parameters: + // - failFast: Propagates failures for explicit maintenance operations such as timezone rebuilds. + private fun recordAllLiveWeeklyReports(failFast: Boolean = false) { + val zone = GameTimeZonePreferences.get(appContext) WeeklyReportRange.periodStarts(access { it.listWeeklyEvidenceDays(zone) }) .forEach { periodStart -> - recordHistory( - buildLiveWeeklyReport(periodStart, zone, Instant.now()), - Instant.now(), - clearActiveOnChange = true, - ) + if (failFast) { + val now = Instant.now() + recordHistory( + buildLiveWeeklyRevision(periodStart, zone, now), + now, + clearActiveOnChange = true, + ) + } else { + recordLiveWeeklyRevisionSafely(periodStart, zone) + } } } private fun recordHistory( - report: WeeklyReportBuilder.Report, + revision: WeeklyTableRevision, recordedAt: Instant, clearActiveOnChange: Boolean, ) { - val encoded = WeeklyReportHistoryCodec.encode(report) + val encoded = WeeklyReportHistoryCodec.encode(revision) access { database -> database.recordWeeklyReportHistory( - periodStartEpochDay = report.periodStart.toEpochDay(), + periodStartEpochDay = revision.report.periodStart.toEpochDay(), recordedAt = recordedAt, fingerprint = encoded.fingerprint, payload = encoded.payload, @@ -369,6 +443,42 @@ class PlatoonRepository(context: Context) { } } + private fun recordLiveWeeklyRevision(periodStart: LocalDate) { + val zone = GameTimeZonePreferences.get(appContext) + recordLiveWeeklyRevisionSafely(periodStart, zone) + } + + // Function Name: recordLiveWeeklyRevisionSafely + // Description: + // - Records a derived table revision without changing the result of an already committed mutation. + // - Clears a restored projection on failure so the next screen render uses authoritative live data. + // Parameters: + // - periodStart: Sunday key of the weekly table to refresh. + // - zone: Persisted game timezone used by every date boundary in the projection. + private fun recordLiveWeeklyRevisionSafely(periodStart: LocalDate, zone: ZoneId) { + try { + val now = Instant.now() + recordHistory( + buildLiveWeeklyRevision(periodStart, zone, now), + now, + clearActiveOnChange = true, + ) + } catch (historyError: Exception) { + try { + access { database -> + database.clearActiveWeeklyReportHistory(periodStart.toEpochDay()) + } + } catch (clearError: Exception) { + historyError.addSuppressed(clearError) + } + Log.e( + TAG, + "Unable to refresh weekly history for $periodStart; live data remains authoritative", + historyError, + ) + } + } + private fun access(block: (PlatoonDatabase) -> T): T = withDatabase(appContext, block) @@ -392,6 +502,13 @@ class PlatoonRepository(context: Context) { companion object { const val RETAINED_CSV_DIRECTORY = "guild-members" + private const val MAX_REVISION_EVENTS = 512 + private const val MAX_REVISION_NOTES = 128 + private const val MAX_REVISION_NAMES = 768 + private const val MAX_REVISION_NAME_BYTES = 1_024 + private const val MAX_REVISION_NOTE_BYTES = 16 * 1_024 + private const val MAX_REVISION_PRIVATE_NOTE_BYTES = 2 * 1_024 + private const val TAG = "GF2PlatoonRepository" private val databaseLock = Any() private val maintenanceLock = ReentrantReadWriteLock(true) @@ -422,6 +539,23 @@ class PlatoonRepository(context: Context) { } } +private fun String.boundedUtf8(maximumBytes: Int): String { + if (toByteArray(Charsets.UTF_8).size <= maximumBytes) return this + val result = StringBuilder() + var byteCount = 0 + var offset = 0 + while (offset < length) { + val codePoint = codePointAt(offset) + val character = String(Character.toChars(codePoint)) + val characterBytes = character.toByteArray(Charsets.UTF_8).size + if (byteCount + characterBytes > maximumBytes) break + result.append(character) + byteCount += characterBytes + offset += Character.charCount(codePoint) + } + return result.toString() +} + private fun GuildMember.toSnapshotMember() = SnapshotMember( uid = uid.toLong(), name = name, diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonSchema.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonSchema.kt index ea7e85b..3b827fc 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonSchema.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonSchema.kt @@ -5,7 +5,7 @@ package dev.gf2log.app.management */ internal object PlatoonSchema { const val DATABASE_NAME = "platoon.db" - const val CURRENT_VERSION = 12 + const val CURRENT_VERSION = 13 const val MIN_BACKUP_VERSION = 1 private val baseTables = setOf( diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt index 86c7c86..7327098 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt @@ -15,9 +15,19 @@ import java.util.zip.GZIPOutputStream internal object WeeklyReportHistoryCodec { data class Encoded(val payload: ByteArray, val fingerprint: String) - fun encode(report: WeeklyReportBuilder.Report): Encoded { + fun encode(report: WeeklyReportBuilder.Report): Encoded = encode( + WeeklyTableRevision(report, emptyList(), emptyList(), emptyMap(), emptyMap()), + ) + + fun encode(revision: WeeklyTableRevision): Encoded { + ManagementConsistencyAudit.requireValid(revision) + val report = revision.report require(report.days.size == DAYS_PER_WEEK) require(report.members.size <= MAX_MEMBERS) + require(revision.membershipEvents.size <= MAX_EVENTS) + require(revision.notes.size <= MAX_NOTES) + require(revision.memberNamesByUid.size <= MAX_CONTEXT_MEMBERS) + require(revision.memberPrivateNotesByUid.size <= MAX_MEMBERS) val raw = ByteArrayOutputStream().use { buffer -> DataOutputStream(buffer).use { output -> output.writeInt(MAGIC) @@ -29,9 +39,16 @@ internal object WeeklyReportHistoryCodec { report.days.forEach { output.writeLong(it.toEpochDay()) } output.writeInt(report.members.size) report.members.forEach { output.writeMember(it) } + output.writeInt(revision.membershipEvents.size) + revision.membershipEvents.forEach { output.writeMemberEvent(it) } + output.writeInt(revision.notes.size) + revision.notes.forEach { output.writeWeeklyNote(it) } + output.writeStringMap(revision.memberNamesByUid, MAX_STRING_BYTES) + output.writeStringMap(revision.memberPrivateNotesByUid, MAX_PRIVATE_NOTE_BYTES) } buffer.toByteArray() } + require(raw.size <= MAX_DECOMPRESSED_BYTES) { "Weekly history payload expands beyond its limit" } val payload = ByteArrayOutputStream().use { compressed -> GZIPOutputStream(compressed).use { it.write(raw) } compressed.toByteArray() @@ -43,7 +60,9 @@ internal object WeeklyReportHistoryCodec { return Encoded(payload, fingerprint) } - fun decode(payload: ByteArray): WeeklyReportBuilder.Report { + fun decode(payload: ByteArray): WeeklyReportBuilder.Report = decodeRevision(payload).report + + fun decodeRevision(payload: ByteArray): WeeklyTableRevision { require(payload.size in 1..MAX_PAYLOAD_BYTES) { "Invalid weekly history payload size" } val raw = GZIPInputStream(ByteArrayInputStream(payload)).use { compressed -> val output = ByteArrayOutputStream() @@ -62,7 +81,10 @@ internal object WeeklyReportHistoryCodec { } return DataInputStream(ByteArrayInputStream(raw)).use { input -> require(input.readInt() == MAGIC) { "Invalid weekly history header" } - require(input.readInt() == FORMAT_VERSION) { "Unsupported weekly history format" } + val formatVersion = input.readInt() + require(formatVersion in LEGACY_FORMAT_VERSION..FORMAT_VERSION) { + "Unsupported weekly history format" + } val periodStart = LocalDate.ofEpochDay(input.readLong()) val periodEnd = LocalDate.ofEpochDay(input.readLong()) val gunsmoke = input.readBoolean() @@ -72,8 +94,91 @@ internal object WeeklyReportHistoryCodec { require(days == List(DAYS_PER_WEEK) { periodStart.plusDays(it.toLong()) }) val memberCount = input.readBoundedCount(MAX_MEMBERS) val members = List(memberCount) { input.readMember(gunsmoke, periodStart) } + val report = WeeklyReportBuilder.Report(periodStart, periodEnd, gunsmoke, days, members) + val revision = if (formatVersion == LEGACY_FORMAT_VERSION) { + WeeklyTableRevision(report, emptyList(), emptyList(), emptyMap(), emptyMap()) + } else { + val eventCount = input.readBoundedCount(MAX_EVENTS) + val events = List(eventCount) { input.readMemberEvent() } + val noteCount = input.readBoundedCount(MAX_NOTES) + val notes = List(noteCount) { input.readWeeklyNote(periodStart) } + val names = input.readStringMap(MAX_CONTEXT_MEMBERS, MAX_STRING_BYTES) + val privateNotes = input.readStringMap(MAX_MEMBERS, MAX_PRIVATE_NOTE_BYTES) + WeeklyTableRevision(report, events, notes, names, privateNotes) + } require(input.read() == -1) { "Trailing weekly history data" } - WeeklyReportBuilder.Report(periodStart, periodEnd, gunsmoke, days, members) + ManagementConsistencyAudit.requireValid(revision) + } + } + + private fun DataOutputStream.writeMemberEvent(event: MemberEvent) { + writeLong(event.id) + writeLong(event.uid) + writeInt(event.type.ordinal) + writeNullableLong(event.occurredAt?.toEpochMilli()) + writeNullableLong(event.eventDate?.toEpochDay()) + writeBoolean(event.timeKnown) + writeLong(event.observedAt.toEpochMilli()) + writeInt(event.precision.ordinal) + writeInt(event.source.ordinal) + writeBoundedString(event.note, MAX_NOTE_BYTES) + } + + private fun DataInputStream.readMemberEvent() = MemberEvent( + id = readLong(), + uid = readLong(), + type = readEnum(), + occurredAt = readNullableLong()?.let(Instant::ofEpochMilli), + eventDate = readNullableLong()?.let(LocalDate::ofEpochDay), + timeKnown = readBoolean(), + observedAt = Instant.ofEpochMilli(readLong()), + precision = readEnum(), + source = readEnum(), + note = readBoundedString(MAX_NOTE_BYTES), + ) + + private fun DataOutputStream.writeWeeklyNote(note: WeeklyNote) { + writeLong(note.id) + writeLong(note.periodStart.toEpochDay()) + writeLong(note.gameDay.toEpochDay()) + writeBoundedString(note.text, MAX_NOTE_BYTES) + writeNullableLong(note.eventId) + writeBoolean(note.isAutomatic) + } + + private fun DataInputStream.readWeeklyNote(expectedPeriodStart: LocalDate): WeeklyNote { + val note = WeeklyNote( + id = readLong(), + periodStart = LocalDate.ofEpochDay(readLong()), + gameDay = LocalDate.ofEpochDay(readLong()), + text = readBoundedString(MAX_NOTE_BYTES), + eventId = readNullableLong(), + isAutomatic = readBoolean(), + ) + require(note.periodStart == expectedPeriodStart) + require(note.gameDay in expectedPeriodStart..expectedPeriodStart.plusDays(6)) + return note + } + + private fun DataOutputStream.writeStringMap(values: Map, maximumStringBytes: Int) { + writeInt(values.size) + values.toSortedMap().forEach { (uid, value) -> + writeLong(uid) + writeBoundedString(value, maximumStringBytes) + } + } + + private fun DataInputStream.readStringMap( + maximumEntries: Int, + maximumStringBytes: Int, + ): Map { + val count = readBoundedCount(maximumEntries) + return buildMap { + repeat(count) { + val uid = readLong() + require(uid > 0L && uid !in this) + put(uid, readBoundedString(maximumStringBytes)) + } } } @@ -243,15 +348,18 @@ internal object WeeklyReportHistoryCodec { ) } - private fun DataOutputStream.writeBoundedString(value: String) { + private fun DataOutputStream.writeBoundedString( + value: String, + maximumBytes: Int = MAX_STRING_BYTES, + ) { val bytes = value.toByteArray(StandardCharsets.UTF_8) - require(bytes.size <= MAX_STRING_BYTES) + require(bytes.size <= maximumBytes) writeInt(bytes.size) write(bytes) } - private fun DataInputStream.readBoundedString(): String { - val size = readBoundedCount(MAX_STRING_BYTES) + private fun DataInputStream.readBoundedString(maximumBytes: Int = MAX_STRING_BYTES): String { + val size = readBoundedCount(maximumBytes) val bytes = ByteArray(size) readFully(bytes) return String(bytes, StandardCharsets.UTF_8) @@ -297,10 +405,16 @@ internal object WeeklyReportHistoryCodec { } private const val MAGIC = 0x47463248 - private const val FORMAT_VERSION = 1 + private const val LEGACY_FORMAT_VERSION = 1 + private const val FORMAT_VERSION = 2 private const val DAYS_PER_WEEK = 7 private const val MAX_MEMBERS = 256 + private const val MAX_CONTEXT_MEMBERS = 768 + private const val MAX_EVENTS = 512 + private const val MAX_NOTES = 128 private const val MAX_STRING_BYTES = 1_024 + private const val MAX_NOTE_BYTES = 16 * 1_024 + private const val MAX_PRIVATE_NOTE_BYTES = 2 * 1_024 private const val MAX_DECOMPRESSED_BYTES = 4 * 1024 * 1024 private const val STREAM_BUFFER_BYTES = 8 * 1024 const val MAX_PAYLOAD_BYTES = 2 * 1024 * 1024 diff --git a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt index 9d84cd6..5d11e3a 100644 --- a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt +++ b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt @@ -9,6 +9,8 @@ import java.util.Properties data class AppBackupSettings( val language: String, val themeMode: String, + val gameServerRegion: String, + val gameTimeZoneId: String, val onboardingCompleted: Boolean, val detailedNotifications: Boolean, val targetPackage: String, @@ -18,12 +20,16 @@ data class AppBackupSettings( ) object AppBackupSettingsCodec { - private const val SCHEMA_VERSION = 2 + private const val SCHEMA_VERSION = 4 + private const val TIME_ZONE_SCHEMA_VERSION = 3 + private const val THEME_SCHEMA_VERSION = 2 private const val LEGACY_SCHEMA_VERSION = 1 private const val NONE = "none" private const val KEY_SCHEMA_VERSION = "schemaVersion" private const val KEY_LANGUAGE = "language" private const val KEY_THEME_MODE = "themeMode" + private const val KEY_GAME_SERVER_REGION = "gameServerRegion" + private const val KEY_GAME_TIME_ZONE = "gameTimeZone" private const val KEY_ONBOARDING_COMPLETED = "onboardingCompleted" private const val KEY_DETAILED_NOTIFICATIONS = "detailedNotifications" private const val KEY_TARGET_PACKAGE = "targetPackage" @@ -43,6 +49,8 @@ object AppBackupSettingsCodec { setProperty(KEY_SCHEMA_VERSION, SCHEMA_VERSION.toString()) setProperty(KEY_LANGUAGE, settings.language) setProperty(KEY_THEME_MODE, settings.themeMode) + setProperty(KEY_GAME_SERVER_REGION, settings.gameServerRegion) + setProperty(KEY_GAME_TIME_ZONE, settings.gameTimeZoneId) setProperty(KEY_ONBOARDING_COMPLETED, settings.onboardingCompleted.toString()) setProperty(KEY_DETAILED_NOTIFICATIONS, settings.detailedNotifications.toString()) setProperty(KEY_TARGET_PACKAGE, settings.targetPackage) @@ -68,8 +76,10 @@ object AppBackupSettingsCodec { val properties = StrictProperties("Backup settings").apply { ByteArrayInputStream(bytes).use(::load) } - val schemaVersion = properties.required(KEY_SCHEMA_VERSION).toIntOrNull() - require(schemaVersion == LEGACY_SCHEMA_VERSION || schemaVersion == SCHEMA_VERSION) { + val schemaVersion = requireNotNull(properties.required(KEY_SCHEMA_VERSION).toIntOrNull()) { + "Backup settings schema is invalid" + } + require(schemaVersion in LEGACY_SCHEMA_VERSION..SCHEMA_VERSION) { "Unsupported settings schema" } require(properties.stringPropertyNames() == expectedKeys(schemaVersion)) { @@ -77,12 +87,22 @@ object AppBackupSettingsCodec { } val settings = AppBackupSettings( language = properties.required(KEY_LANGUAGE), - themeMode = if (schemaVersion == SCHEMA_VERSION) { + themeMode = if (schemaVersion >= THEME_SCHEMA_VERSION) { properties.required(KEY_THEME_MODE) } else { "system" }, - onboardingCompleted = if (schemaVersion == SCHEMA_VERSION) { + gameServerRegion = if (schemaVersion >= SCHEMA_VERSION) { + properties.required(KEY_GAME_SERVER_REGION) + } else { + GameServerRegion.MANUAL.storedValue + }, + gameTimeZoneId = if (schemaVersion >= TIME_ZONE_SCHEMA_VERSION) { + properties.required(KEY_GAME_TIME_ZONE) + } else { + java.time.ZoneId.systemDefault().id + }, + onboardingCompleted = if (schemaVersion >= THEME_SCHEMA_VERSION) { properties.strictBoolean(KEY_ONBOARDING_COMPLETED) } else { true @@ -90,7 +110,14 @@ object AppBackupSettingsCodec { detailedNotifications = properties.strictBoolean(KEY_DETAILED_NOTIFICATIONS), targetPackage = properties.required(KEY_TARGET_PACKAGE), payloadHistory = PayloadCatalog.categories.associate { category -> - category.payloadType to properties.strictBoolean(payloadKey(category.payloadType)) + category.payloadType to if ( + schemaVersion < SCHEMA_VERSION && + category.payloadType == dev.gf2log.protocol.Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE + ) { + true + } else { + properties.strictBoolean(payloadKey(category.payloadType)) + } }, memberOrder = properties.memberOrder(), weeklyCutlines = WeeklyCutlines( @@ -113,6 +140,13 @@ object AppBackupSettingsCodec { require(settings.themeMode in setOf("system", "light", "dark")) { "Unsupported display theme" } + require( + GameServerRegion.fromStored(settings.gameServerRegion).storedValue == + settings.gameServerRegion, + ) { "Unsupported game server region" } + require(runCatching { java.time.ZoneId.of(settings.gameTimeZoneId) }.isSuccess) { + "Unsupported game timezone" + } require( settings.targetPackage.length in 3..255 && PACKAGE_NAME.matches(settings.targetPackage), @@ -141,8 +175,17 @@ object AppBackupSettingsCodec { } private fun expectedKeys(schemaVersion: Int): Set = - (if (schemaVersion == SCHEMA_VERSION) BASE_KEYS else LEGACY_BASE_KEYS) + - PayloadCatalog.categories.map { payloadKey(it.payloadType) } + when (schemaVersion) { + SCHEMA_VERSION -> BASE_KEYS + TIME_ZONE_SCHEMA_VERSION -> BASE_KEYS - KEY_GAME_SERVER_REGION + THEME_SCHEMA_VERSION -> BASE_KEYS - setOf(KEY_GAME_SERVER_REGION, KEY_GAME_TIME_ZONE) + else -> LEGACY_BASE_KEYS + } + PayloadCatalog.categories + .filterNot { + schemaVersion < SCHEMA_VERSION && + it.payloadType == dev.gf2log.protocol.Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE + } + .map { payloadKey(it.payloadType) } private fun payloadKey(payloadType: Int) = "payloadHistory.$payloadType" @@ -195,6 +238,8 @@ object AppBackupSettingsCodec { KEY_SCHEMA_VERSION, KEY_LANGUAGE, KEY_THEME_MODE, + KEY_GAME_SERVER_REGION, + KEY_GAME_TIME_ZONE, KEY_ONBOARDING_COMPLETED, KEY_DETAILED_NOTIFICATIONS, KEY_TARGET_PACKAGE, @@ -208,5 +253,11 @@ object AppBackupSettingsCodec { KEY_WEEKLY_LOGIN_DAYS, KEY_WEEKLY_PATROL_DAYS, ) - private val LEGACY_BASE_KEYS = BASE_KEYS - setOf(KEY_THEME_MODE, KEY_ONBOARDING_COMPLETED) + private val LEGACY_BASE_KEYS = BASE_KEYS - + setOf( + KEY_THEME_MODE, + KEY_GAME_SERVER_REGION, + KEY_GAME_TIME_ZONE, + KEY_ONBOARDING_COMPLETED, + ) } diff --git a/app/src/main/java/dev/gf2log/app/settings/GameServerRegion.kt b/app/src/main/java/dev/gf2log/app/settings/GameServerRegion.kt new file mode 100644 index 0000000..3f77680 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/settings/GameServerRegion.kt @@ -0,0 +1,37 @@ +package dev.gf2log.app.settings + +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime + +/** Known publisher/region reset schedules, expressed without device-local assumptions. */ +internal enum class GameServerRegion( + val storedValue: String, + val serverZone: ZoneId?, +) { + MANUAL("manual", null), + DARKWINTER_GLOBAL("darkwinter_global", ZoneOffset.ofHours(-4)), + DARKWINTER_CHINA("darkwinter_china", ZoneOffset.ofHours(8)), + HAOPLAY_GLOBAL("haoplay_global", ZoneOffset.UTC), + HAOPLAY_JAPAN("haoplay_japan", ZoneOffset.ofHours(9)), + HAOPLAY_KOREA("haoplay_korea", ZoneOffset.ofHours(9)), + HAOPLAY_ASIA("haoplay_asia", ZoneOffset.ofHours(8)), + ; + + fun nextReset(after: Instant = Instant.now()): Instant { + val zone = requireNotNull(serverZone) + val now = after.atZone(zone) + val today = ZonedDateTime.of(now.toLocalDate(), RESET_TIME, zone) + return (if (today.toInstant().isAfter(after)) today else today.plusDays(1)).toInstant() + } + + companion object { + val RESET_TIME: LocalTime = LocalTime.of(5, 0) + + fun fromStored(value: String?): GameServerRegion = entries + .firstOrNull { it.storedValue == value } + ?: MANUAL + } +} diff --git a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt new file mode 100644 index 0000000..cb79582 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt @@ -0,0 +1,28 @@ +package dev.gf2log.app.settings + +import android.content.Context +import java.time.ZoneId + +/** Provides the one persisted timezone used for every game-day and weekly boundary. */ +internal object GameTimeZonePreferences { + fun get(context: Context): ZoneId { + val region = region(context) + return region.serverZone ?: runCatching { + ZoneId.of(UserSettingsPreferences.gameTimeZoneId(context)) + }.getOrElse { ZoneId.systemDefault() } + } + + fun deviceZone(): ZoneId = ZoneId.systemDefault() + + fun region(context: Context): GameServerRegion = GameServerRegion.fromStored( + UserSettingsPreferences.gameServerRegion(context), + ) + + fun setRegion(context: Context, region: GameServerRegion) = + UserSettingsPreferences.setGameServerRegion(context, region.storedValue) + + fun set(context: Context, zoneId: ZoneId) { + UserSettingsPreferences.setGameTimeZoneId(context, zoneId.id) + setRegion(context, GameServerRegion.MANUAL) + } +} diff --git a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt index e86f186..b1fcc26 100644 --- a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt @@ -5,14 +5,17 @@ import android.content.SharedPreferences import dev.gf2log.app.LanguagePreferences import dev.gf2log.app.TargetPackagePreferences import dev.gf2log.protocol.PayloadCatalog +import java.time.ZoneId internal object UserSettingsPreferences { private const val PREFERENCES = "user_settings" - private const val SCHEMA_VERSION = 2 + private const val SCHEMA_VERSION = 4 private const val LEGACY_UNIFIED_SCHEMA_VERSION = 1 private const val KEY_SCHEMA_VERSION = "schema_version" private const val KEY_LANGUAGE = "language" private const val KEY_THEME_MODE = "theme_mode" + private const val KEY_GAME_SERVER_REGION = "game_server_region" + private const val KEY_GAME_TIME_ZONE = "game_time_zone" private const val KEY_ONBOARDING_COMPLETED = "onboarding_completed" private const val KEY_DETAILED_NOTIFICATIONS = "detailed_notifications" private const val KEY_TARGET_PACKAGE = "target_package" @@ -60,6 +63,28 @@ internal object UserSettingsPreferences { putString(KEY_THEME_MODE, mode) } + fun gameTimeZoneId(context: Context): String = synchronized(lock) { + preferencesLocked(context.applicationContext) + .getString(KEY_GAME_TIME_ZONE, ZoneId.systemDefault().id) + .orEmpty() + } + + fun gameServerRegion(context: Context): String = synchronized(lock) { + preferencesLocked(context.applicationContext) + .getString(KEY_GAME_SERVER_REGION, GameServerRegion.MANUAL.storedValue) + .orEmpty() + } + + fun setGameServerRegion(context: Context, region: String) { + require(GameServerRegion.fromStored(region).storedValue == region) + edit(context) { putString(KEY_GAME_SERVER_REGION, region) } + } + + fun setGameTimeZoneId(context: Context, zoneId: String) { + ZoneId.of(zoneId) + edit(context) { putString(KEY_GAME_TIME_ZONE, zoneId) } + } + fun onboardingCompleted(context: Context): Boolean = synchronized(lock) { preferencesLocked(context.applicationContext) .getBoolean(KEY_ONBOARDING_COMPLETED, false) @@ -133,11 +158,17 @@ internal object UserSettingsPreferences { val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) val storedVersion = preferences.getInt(KEY_SCHEMA_VERSION, 0) if (storedVersion == SCHEMA_VERSION) return preferences - val migrated = if (storedVersion == LEGACY_UNIFIED_SCHEMA_VERSION) { + val migrated = if (storedVersion in LEGACY_UNIFIED_SCHEMA_VERSION until SCHEMA_VERSION) { // v2.2.0 already used the unified store. Preserve it and keep the // new-user walkthrough exclusive to genuinely fresh installs. readLocked(preferences).copy( onboardingCompleted = preferences.getBoolean(KEY_ONBOARDING_COMPLETED, true), + gameServerRegion = preferences.getString( + KEY_GAME_SERVER_REGION, + GameServerRegion.MANUAL.storedValue, + ).orEmpty(), + gameTimeZoneId = preferences.getString(KEY_GAME_TIME_ZONE, ZoneId.systemDefault().id) + .orEmpty(), ) } else { readLegacy(context) @@ -152,6 +183,11 @@ internal object UserSettingsPreferences { language = preferences.getString(KEY_LANGUAGE, LanguagePreferences.DEFAULT_LANGUAGE) .orEmpty(), themeMode = preferences.getString(KEY_THEME_MODE, "system").orEmpty(), + gameServerRegion = preferences.getString( + KEY_GAME_SERVER_REGION, + GameServerRegion.MANUAL.storedValue, + ).orEmpty(), + gameTimeZoneId = preferences.getString(KEY_GAME_TIME_ZONE, ZoneId.systemDefault().id).orEmpty(), onboardingCompleted = preferences.getBoolean(KEY_ONBOARDING_COMPLETED, false), detailedNotifications = preferences.getBoolean(KEY_DETAILED_NOTIFICATIONS, true), targetPackage = preferences.getString( @@ -195,6 +231,8 @@ internal object UserSettingsPreferences { return AppBackupSettings( language = language, themeMode = "system", + gameServerRegion = GameServerRegion.MANUAL.storedValue, + gameTimeZoneId = ZoneId.systemDefault().id, onboardingCompleted = false, detailedNotifications = detailedNotifications, targetPackage = targetPackage, @@ -270,6 +308,8 @@ internal object UserSettingsPreferences { .putInt(KEY_SCHEMA_VERSION, SCHEMA_VERSION) .putString(KEY_LANGUAGE, settings.language) .putString(KEY_THEME_MODE, settings.themeMode) + .putString(KEY_GAME_SERVER_REGION, settings.gameServerRegion) + .putString(KEY_GAME_TIME_ZONE, settings.gameTimeZoneId) .putBoolean(KEY_ONBOARDING_COMPLETED, settings.onboardingCompleted) .putBoolean(KEY_DETAILED_NOTIFICATIONS, settings.detailedNotifications) .putString(KEY_TARGET_PACKAGE, settings.targetPackage) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index bad7bbb..bb49f0c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -20,7 +20,7 @@ 설치된 HaoPlay 및 Darkwinter 클라이언트의 프로토콜을 기기에서 캡처합니다. 원본 트래픽은 저장하지 않습니다. 캡처 준비 한 번만 캡처 - %1$s 서클 명단\n%2$s 활동 근거\n%3$s 가입/탈퇴 변경 + %1$s 서클 식별 정보\n%2$s 서클 명단\n%3$s 활동 근거\n%4$s 가입/탈퇴 변경 중지 설정 열기 서클 관리 백업 내보내기 @@ -50,7 +50,7 @@ 메인 화면 소녀전선2: 망명 클라이언트의 통신 패킷을 캡처하고, 패킷 수집 현황을 한눈에 확인합니다. 캡처 준비는 사용자가 중지할 때까지 계속 실행됩니다. - 한 번만 캡처는 멤버, 활동, 동향을 확인한 뒤 자동으로 중지합니다. + 한 번만 캡처는 서클 식별 정보, 멤버, 활동, 동향을 확인한 뒤 자동으로 중지합니다. 최근 패킷 및 저장한 패킷 기록은 로컬에 보관됩니다. VPN 동의 화면이 표시됩니다. GF2logger는 소녀전선2: 망명 앱에 대해서만 통신 패킷을 관찰하며, TLS를 해독하거나 원본 네트워크 패킷을 저장하지 않습니다. 설정 @@ -196,6 +196,22 @@ 앞으로 기록할 디코딩 페이로드를 선택합니다. 기존 기록에는 영향을 주지 않습니다. 패킷과 보고서의 표기 언어에도 함께 적용됩니다. 기기 설정을 따르거나 라이트·다크 모드로 고정합니다. + 게임 시간대 + 하루 초기화 시간 + 게임 서버 지역을 선택하면 서버의 05:00 초기화 시각을 이 기기의 시간대로 환산합니다. + 서버 지역 + 시간대 수동 설정 + Darkwinter 글로벌 + Darkwinter 중국 + HaoPlay 글로벌 + HaoPlay 일본 + HaoPlay 한국 + HaoPlay 아시아 + %1$s · 이 기기의 다음 초기화 %2$s (%3$s) + 수동 · %1$s (기기: %2$s) + 게임: %1$s · 기기: %2$s + 게임 시간대를 변경하고 주간 표 히스토리를 다시 구성했습니다 + 시간대 변경과 주간 표 히스토리 재구성을 완료하지 못했습니다 사용자가 직접 선택한 백업 파일로만 전체 앱 데이터를 이동합니다. 뒤로 %1$s (payloadType = %2$d) @@ -203,6 +219,7 @@ %1$s, 페이로드 유형 %2$s 알 수 없음 서클 멤버 + 서클 프로필 서클 활동 서클 동향 무기 @@ -217,6 +234,7 @@ 공용키 편성 서클 멤버, 공적 합계, 점수 및 최근 접속 시각입니다. + 캡처 기록을 분리하는 데 사용하는 서클 ID, 이름 및 엠블럼·스타일 식별자입니다. 증분 방식의 일일 과업 근거 등을 포함한 시각 기반 서클 활동입니다. UID와 정확한 시각이 포함된 가입, 탈퇴, 추방 및 과업 보급 생성 여부입니다. 보유 무기와 재련 정보입니다. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eecff28..6e8ee73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,7 +20,7 @@ On-device protocol capture for installed HaoPlay and Darkwinter clients. Raw traffic is not stored. Prepare capture One-time Capture - %1$s Platoon roster\n%2$s Activity evidence\n%3$s Membership updates + %1$s Platoon identity\n%2$s Platoon roster\n%3$s Activity evidence\n%4$s Membership updates Stop Open payload options Export Platoon backup @@ -50,7 +50,7 @@ Main page Capture communications from the GIRLS\' FRONTLINE 2: EXILIUM client and see packet collection progress at a glance. Prepare capture keeps running until you stop it. - One-time Capture checks Members, Activity, and Updates, then stops automatically. + One-time Capture checks Platoon identity, Members, Activity, and Updates, then stops automatically. Recent and saved packet history is stored locally. Android will show its VPN consent screen. GF2logger observes communications only for GIRLS\' FRONTLINE 2: EXILIUM, does not decrypt TLS, and never stores raw network packets. Settings @@ -196,6 +196,22 @@ Choose which decoded payloads are added to future packet history. Existing history is unchanged. This choice also applies to packet and report labels. Follow the device, or keep GF2logger in Light or Dark mode. + Game timezone + Daily reset time + Choose the game server region. The next 05:00 server reset is converted to this phone’s timezone. + Server region + Manual timezone + Darkwinter Global + Darkwinter China + HaoPlay Global + HaoPlay Japan + HaoPlay South Korea + HaoPlay Asia + %1$s · next reset %2$s on this phone (%3$s) + Manual · %1$s (phone: %2$s) + Game: %1$s · phone: %2$s + Game timezone updated and weekly history rebuilt + Unable to complete the timezone change and weekly-history rebuild Move complete app data only through a backup you explicitly choose. Back %1$s (payloadType = %2$d) @@ -203,6 +219,7 @@ %1$s, payload type %2$s unknown PLATOON MEMBERS + PLATOON PROFILE PLATOON ACTIVITY PLATOON UPDATES WEAPONS @@ -217,6 +234,7 @@ COMMON KEYS FORMATIONS Platoon members, merit totals, scores, and last-login times. + Platoon ID, name, and emblem/style identifiers used to keep captured records isolated. Timestamped Platoon activity, including incremental Daily Patrol evidence. Exact join, withdrawal, removal, and Daily Patrol updates with member UIDs and timestamps. Owned weapons and their upgrade data. diff --git a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverPropertyTest.kt b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverPropertyTest.kt new file mode 100644 index 0000000..9b05e61 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverPropertyTest.kt @@ -0,0 +1,145 @@ +package dev.gf2log.app.management + +import java.time.LocalDate +import java.time.ZoneId +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GunsmokeWeekSolverPropertyTest { + private val zone = ZoneId.of("Asia/Seoul") + private val periodStart = LocalDate.of(2026, 7, 19) + + @Test(timeout = 60_000) + fun sparseValidCaptureRoutinesRemainConservativeAndOrderIndependent() { + val random = Random(0x2_3_3) + var publishedMeritChecks = 0 + var publishedScoreChecks = 0 + repeat(128) { caseIndex -> + val truth = List(7) { + val attempts = random.nextInt(0, 4) + val attemptScores = List(attempts) { random.nextInt(0, 10_001).toLong() } + val score = attemptScores.sum() + val attended = attempts > 0 || random.nextBoolean() + val patrol = attended && random.nextBoolean() + val merit = attempts * ActivityInference.MERIT_PER_ATTEMPT + + attemptScores.sumOf { attemptScore -> attemptScore / 10L } + + (if (attended) 50L else 0L) + (if (patrol) 40L else 0L) + Truth(merit, score, attended, patrol) + } + val complete = snapshots(caseIndex, truth) + val selected = complete.filterIndexed { index, _ -> + index == 0 || index == complete.lastIndex || random.nextBoolean() + } + + fun build(input: List) = WeeklyReportBuilder.build( + referenceDay = periodStart.plusDays(3), + zoneId = zone, + snapshots = input, + asOf = periodStart.plusDays(8).atTime(6, 0).atZone(zone).toInstant(), + ).members.single().days + + fun assertConservative(label: String, cells: List) { + cells.zip(truth).forEach { (cell, actual) -> + cell.meritDelta?.let { published -> + publishedMeritChecks += 1 + assertTrue("$label merit floor case $caseIndex on ${cell.gameDay}", published <= actual.merit) + } + cell.scoreDelta?.let { published -> + publishedScoreChecks += 1 + assertTrue("$label score floor case $caseIndex on ${cell.gameDay}", published <= actual.score) + } + if (cell.meritCertainty == MetricCertainty.EXACT) { + assertEquals("$label exact merit case $caseIndex on ${cell.gameDay}", actual.merit, cell.meritDelta) + } + if (cell.scoreCertainty == MetricCertainty.EXACT) { + assertEquals("$label exact score case $caseIndex on ${cell.gameDay}", actual.score, cell.scoreDelta) + } + cell.attended?.let { attended -> + assertEquals("$label attendance case $caseIndex on ${cell.gameDay}", actual.attended, attended) + } + cell.dailyPatrol?.let { patrol -> + assertEquals("$label patrol case $caseIndex on ${cell.gameDay}", actual.patrol, patrol) + } + } + } + assertConservative("complete", build(complete)) + val sparseCells = build(selected) + assertConservative("sparse", sparseCells) + assertEquals( + "order independence case $caseIndex", + sparseCells.map { Triple(it.meritDelta, it.scoreDelta, it.meritCertainty to it.scoreCertainty) }, + build(selected.reversed()).map { + Triple(it.meritDelta, it.scoreDelta, it.meritCertainty to it.scoreCertainty) + }, + ) + } + assertTrue("generated cases must exercise merit publication", publishedMeritChecks > 0) + assertTrue("generated cases must exercise score publication", publishedScoreChecks > 0) + } + + private fun snapshots(caseIndex: Int, truth: List): List { + var totalMerit = 100_000L + var totalScore = 0L + var weeklyMerit = 6L * 90L + val snapshots = mutableListOf( + snapshot( + caseIndex, + 0, + periodStart.atTime(4, 59).atZone(zone).toInstant(), + weeklyMerit, + totalMerit, + totalScore, + ), + ) + truth.forEachIndexed { index, day -> + totalMerit += day.merit + totalScore += day.score + weeklyMerit = if (index == 1) day.merit else weeklyMerit + day.merit + snapshots += snapshot( + caseIndex, + index + 1, + periodStart.plusDays(index + 1L).atTime(4, 30).atZone(zone).toInstant(), + weeklyMerit, + totalMerit, + totalScore, + ) + if (index == 0) { + weeklyMerit = 0L + snapshots += snapshot( + caseIndex, + 20, + periodStart.plusDays(1).atTime(5, 1).atZone(zone).toInstant(), + weeklyMerit, + totalMerit, + totalScore, + ) + } + } + return snapshots + } + + private fun snapshot( + caseIndex: Int, + index: Int, + capturedAt: java.time.Instant, + weeklyMerit: Long, + totalMerit: Long, + totalScore: Long, + ) = PlatoonSnapshot( + id = index.toLong(), + capturedAt = capturedAt, + sourceFile = "gunsmoke-property-$caseIndex-$index.csv", + members = listOf( + SnapshotMember(77L, "Property member", 60L, weeklyMerit, totalMerit, 10_000L, totalScore, 0L), + ), + ) + + private data class Truth( + val merit: Long, + val score: Long, + val attended: Boolean, + val patrol: Boolean, + ) +} diff --git a/app/src/test/java/dev/gf2log/app/management/ManagementConsistencyAuditTest.kt b/app/src/test/java/dev/gf2log/app/management/ManagementConsistencyAuditTest.kt new file mode 100644 index 0000000..9afbc81 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/ManagementConsistencyAuditTest.kt @@ -0,0 +1,63 @@ +package dev.gf2log.app.management + +import java.time.LocalDate +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test + +class ManagementConsistencyAuditTest { + @Test + fun acceptsAContiguousWeeklyRevision() { + val revision = revision() + + assertSame(revision, ManagementConsistencyAudit.requireValid(revision)) + } + + @Test + fun rejectsCellsOutsideTheirWeeklyPeriod() { + val revision = revision() + val invalidMember = revision.report.members.single().copy( + days = revision.report.members.single().days.mapIndexed { index, cell -> + if (index == 3) cell.copy(gameDay = cell.gameDay.plusDays(1)) else cell + }, + ) + + assertThrows(IllegalArgumentException::class.java) { + ManagementConsistencyAudit.requireValid( + revision.copy(report = revision.report.copy(members = listOf(invalidMember))), + ) + } + } + + private fun revision(): WeeklyTableRevision { + val start = LocalDate.of(2026, 8, 16) + val days = List(7) { index -> start.plusDays(index.toLong()) } + val member = WeeklyReportBuilder.MemberRow( + uid = 1L, + name = "Member", + days = days.map { day -> + WeeklyReportBuilder.DayCell( + gameDay = day, + meritDelta = null, + scoreDelta = null, + inference = null, + evidence = DailyEvidence.NO_OBSERVATION, + hasDailyPatrolFact = false, + hasLoginFact = false, + hasFinalGunsmokeScore = false, + isGunsmokeWeek = true, + ) + }, + totalMerit = 0L, + totalScore = 0L, + isGunsmokeWeek = true, + ) + return WeeklyTableRevision( + report = WeeklyReportBuilder.Report(start, start.plusDays(6), true, days, listOf(member)), + membershipEvents = emptyList(), + notes = emptyList(), + memberNamesByUid = mapOf(1L to "Member"), + memberPrivateNotesByUid = emptyMap(), + ) + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt new file mode 100644 index 0000000..64c252a --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt @@ -0,0 +1,104 @@ +package dev.gf2log.app.management + +import java.time.Instant +import java.time.LocalDate +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MembershipConsistencyPolicyTest { + @Test + fun adjacentPeriodsAreValidAndTheOpenTailDefinesCurrentActivity() { + val periods = listOf( + interval(1, "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + interval(2, "2026-02-01T00:00:00Z", null), + ) + + assertNull(MembershipConsistencyPolicy.violation(periods)) + assertTrue(MembershipConsistencyPolicy.isActive(periods)) + } + + @Test + fun overlappingAndMultipleOpenPeriodsAreRejected() { + assertNotNull( + MembershipConsistencyPolicy.violation( + listOf( + interval(1, "2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"), + interval(2, "2026-02-01T00:00:00Z", "2026-04-01T00:00:00Z"), + ), + ), + ) + assertNotNull( + MembershipConsistencyPolicy.violation( + listOf( + interval(1, "2026-01-01T00:00:00Z", null), + interval(2, "2026-02-01T00:00:00Z", null), + ), + ), + ) + } + + @Test + fun closedTimelineIsInactive() { + val periods = listOf(interval(1, "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z")) + + assertNull(MembershipConsistencyPolicy.violation(periods)) + assertFalse(MembershipConsistencyPolicy.isActive(periods)) + } + + @Test + fun unknownSnapshotStartDoesNotInventAnOverlap() { + val periods = listOf( + interval(1, null, "2026-01-01T00:00:00Z"), + interval(2, "2026-02-01T00:00:00Z", null), + ) + + assertNull(MembershipConsistencyPolicy.violation(periods)) + assertTrue(MembershipConsistencyPolicy.isActive(periods)) + } + + @Test + fun dateOnlyBoundariesUseCalendarChronologyInsteadOfEntryTime() { + val enteredAt = Instant.parse("2026-07-31T00:00:00Z") + val period = MembershipInterval( + id = 1, + joinedAt = enteredAt, + leftAt = enteredAt, + joinedDate = LocalDate.of(2026, 5, 4), + leftDate = LocalDate.of(2026, 5, 6), + joinedTimeKnown = false, + leftTimeKnown = false, + ) + + assertNull(MembershipConsistencyPolicy.violation(listOf(period))) + } + + @Test + fun uncertainSameDayBoundaryDoesNotInventAnOverlap() { + val first = MembershipInterval( + id = 1, + joinedAt = Instant.parse("2026-05-01T00:00:00Z"), + leftAt = Instant.parse("2026-07-31T00:00:00Z"), + joinedDate = LocalDate.of(2026, 5, 1), + leftDate = LocalDate.of(2026, 5, 4), + leftTimeKnown = false, + ) + val second = MembershipInterval( + id = 2, + joinedAt = Instant.parse("2026-07-31T00:00:00Z"), + leftAt = null, + joinedDate = LocalDate.of(2026, 5, 4), + joinedTimeKnown = false, + ) + + assertNull(MembershipConsistencyPolicy.violation(listOf(first, second))) + } + + private fun interval(id: Long, joined: String?, left: String?) = MembershipInterval( + id = id, + joinedAt = joined?.let(Instant::parse), + leftAt = left?.let(Instant::parse), + ) +} diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt index 642c8c3..12bddd5 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt @@ -71,9 +71,32 @@ class WeeklyReportHistoryCodecTest { ), ) - val first = WeeklyReportHistoryCodec.encode(report) - val second = WeeklyReportHistoryCodec.encode(report) - val decoded = WeeklyReportHistoryCodec.decode(first.payload) + val revision = WeeklyTableRevision( + report = report, + membershipEvents = listOf( + MemberEvent( + id = 7L, + uid = 42L, + type = MemberEventType.REJOINED, + occurredAt = Instant.parse("2026-07-20T04:00:00Z"), + eventDate = LocalDate.of(2026, 7, 20), + timeKnown = true, + observedAt = Instant.parse("2026-07-20T04:01:00Z"), + precision = EvidencePrecision.EXACT, + source = EvidenceSource.GAME_UPDATES, + note = "History member", + ), + ), + notes = listOf( + WeeklyNote(3L, start, start.plusDays(1), "Remember this", null, false), + ), + memberNamesByUid = mapOf(42L to "Custom history name"), + memberPrivateNotesByUid = mapOf(42L to "Private at capture time"), + ) + val first = WeeklyReportHistoryCodec.encode(revision) + val second = WeeklyReportHistoryCodec.encode(revision) + val decodedRevision = WeeklyReportHistoryCodec.decodeRevision(first.payload) + val decoded = decodedRevision.report assertEquals(first.fingerprint, second.fingerprint) assertTrue(first.payload.contentEquals(second.payload)) @@ -88,6 +111,10 @@ class WeeklyReportHistoryCodecTest { report.members.single().days.map { it.attended }, decoded.members.single().days.map { it.attended }, ) + assertEquals(revision.membershipEvents, decodedRevision.membershipEvents) + assertEquals(revision.notes, decodedRevision.notes) + assertEquals(revision.memberNamesByUid, decodedRevision.memberNamesByUid) + assertEquals(revision.memberPrivateNotesByUid, decodedRevision.memberPrivateNotesByUid) } @Test(expected = IllegalArgumentException::class) diff --git a/app/src/test/java/dev/gf2log/app/settings/AppBackupSettingsCodecTest.kt b/app/src/test/java/dev/gf2log/app/settings/AppBackupSettingsCodecTest.kt index 7bc9cee..1dc9750 100644 --- a/app/src/test/java/dev/gf2log/app/settings/AppBackupSettingsCodecTest.kt +++ b/app/src/test/java/dev/gf2log/app/settings/AppBackupSettingsCodecTest.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.settings import dev.gf2log.protocol.PayloadCatalog +import dev.gf2log.protocol.Gfl2PayloadDecoder import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.util.Properties @@ -23,7 +24,10 @@ class AppBackupSettingsCodecTest { val legacy = encodedProperties().apply { setProperty("schemaVersion", "1") remove("themeMode") + remove("gameTimeZone") + remove("gameServerRegion") remove("onboardingCompleted") + remove("payloadHistory.${Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE}") } val restored = AppBackupSettingsCodec.decode(legacy.toBytes()) @@ -32,6 +36,33 @@ class AppBackupSettingsCodecTest { assertEquals(true, restored.onboardingCompleted) } + @Test + fun `restores schema two settings with the current device timezone`() { + val schemaTwo = encodedProperties().apply { + setProperty("schemaVersion", "2") + remove("gameTimeZone") + remove("gameServerRegion") + remove("payloadHistory.${Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE}") + } + + val restored = AppBackupSettingsCodec.decode(schemaTwo.toBytes()) + + assertEquals(java.time.ZoneId.systemDefault().id, restored.gameTimeZoneId) + } + + @Test + fun `restores schema three settings as manual server selection`() { + val schemaThree = encodedProperties().apply { + setProperty("schemaVersion", "3") + remove("gameServerRegion") + remove("payloadHistory.${Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE}") + } + + val restored = AppBackupSettingsCodec.decode(schemaThree.toBytes()) + + assertEquals(GameServerRegion.MANUAL.storedValue, restored.gameServerRegion) + } + @Test fun `rejects a missing required setting`() { val properties = encodedProperties() @@ -106,6 +137,8 @@ class AppBackupSettingsCodecTest { private fun completeSettings() = AppBackupSettings( language = "ko", themeMode = "dark", + gameServerRegion = GameServerRegion.HAOPLAY_KOREA.storedValue, + gameTimeZoneId = "Asia/Seoul", onboardingCompleted = true, detailedNotifications = false, targetPackage = "com.example.game_client", diff --git a/app/src/test/java/dev/gf2log/app/settings/GameServerRegionTest.kt b/app/src/test/java/dev/gf2log/app/settings/GameServerRegionTest.kt new file mode 100644 index 0000000..26e748e --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/settings/GameServerRegionTest.kt @@ -0,0 +1,57 @@ +package dev.gf2log.app.settings + +import java.time.Instant +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Test + +class GameServerRegionTest { + @Test + fun `known regions use the documented 0500 server reset`() { + val beforeReset = Instant.parse("2026-08-23T19:30:00Z") + + assertEquals( + Instant.parse("2026-08-23T20:00:00Z"), + GameServerRegion.HAOPLAY_KOREA.nextReset(beforeReset), + ) + assertEquals( + Instant.parse("2026-08-23T20:00:00Z"), + GameServerRegion.HAOPLAY_JAPAN.nextReset(beforeReset), + ) + assertEquals( + Instant.parse("2026-08-24T05:00:00Z"), + GameServerRegion.HAOPLAY_GLOBAL.nextReset(beforeReset), + ) + assertEquals( + Instant.parse("2026-08-23T21:00:00Z"), + GameServerRegion.HAOPLAY_ASIA.nextReset(beforeReset), + ) + assertEquals( + Instant.parse("2026-08-24T09:00:00Z"), + GameServerRegion.DARKWINTER_GLOBAL.nextReset(beforeReset), + ) + assertEquals( + Instant.parse("2026-08-23T21:00:00Z"), + GameServerRegion.DARKWINTER_CHINA.nextReset(beforeReset), + ) + } + + @Test + fun `reset advances to next day at the exact boundary`() { + val exactReset = Instant.parse("2026-08-23T20:00:00Z") + + assertEquals( + Instant.parse("2026-08-24T20:00:00Z"), + GameServerRegion.HAOPLAY_KOREA.nextReset(exactReset), + ) + } + + @Test + fun `stored values round trip and unknown values remain manual`() { + GameServerRegion.entries.forEach { region -> + assertEquals(region, GameServerRegion.fromStored(region.storedValue)) + } + assertEquals(GameServerRegion.MANUAL, GameServerRegion.fromStored("unknown")) + assertEquals(ZoneOffset.ofHours(-4), GameServerRegion.DARKWINTER_GLOBAL.serverZone) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a05f222..904b399 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,7 +20,7 @@ Native zdtun forwarding/reassembly core Gfl2StreamParser (one bounded instance per live TCP flow) | v -Gfl2PayloadDecoder (seven recognized protobuf message types) +Gfl2PayloadDecoder (eight recognized protobuf message types) | v Typed GameData events @@ -57,6 +57,7 @@ Recognized inner types: | 11021 | Weapons | | 11061 | Attachments | | 11138 | Common keys | +| 21905 | Platoon profile | | 21917 | Platoon members | | 21935 | Platoon activity | | 21960 | Platoon updates | @@ -64,6 +65,14 @@ Recognized inner types: Unknown payload types are skipped without allocation. A recognized but malformed protobuf payload produces a warning event and does not terminate parsing of later messages. +Payload `21905` is the authoritative Platoon identity for the decoded flow. On +Android 10 and newer, the capture service also resolves the original connection +tuple to the owning supported package through Android's VPN owner API. Remote +IP addresses and DNS/SNI labels are diagnostic endpoint metadata only; they are +not persistence keys. The current schema remains single-Platoon until every +database, import, CSV, backup, history, and UI path can enforce one composite +scope without fallback to an unscoped record. + ## Memory and concurrency limits - Each active flow parser is capped at 2 MiB of buffered stream data. A pending @@ -90,7 +99,7 @@ string cells through a shared encoder that quotes delimiters and prefixes formula-like values with an apostrophe. Numeric counters remain numeric, and the protection is never fed back into retained member identity. -The single worker is deliberate: these seven responses are sparse, and avoiding +The single worker is deliberate: these eight responses are sparse, and avoiding a worker pool reduces scheduling, memory, and ordering complexity. If benchmarking later proves this insufficient, partition work by flow while preserving in-flow ordering. @@ -142,8 +151,9 @@ SQLite work. SQLite retains the newest 10,000 activity facts, and one ingestion resolves at most 250 unresolved facts. A database-local cursor rotates those bounded batches through the complete retained backlog. Schema v11 owns the rotation cursor and a global `(captured_at, id)` retention -index; supported v1-v10 backups are upgraded before strict current-schema -validation. +index. Schema v13 repairs the derived active-member projection from canonical +membership periods during upgrade; supported older backups are upgraded before +strict current-schema validation. These limits are enforced again at repository and persistence boundaries so a future caller cannot bypass the protocol-layer checks. @@ -152,7 +162,7 @@ future caller cannot bypass the protocol-layer checks. An Android VPN can observe packet metadata, but it cannot automatically read TLS-protected application data. Like the desktop reference, mobileGF2logger identifies TLS/HTTP flows and forwards them unchanged without parsing. The -seven recognized game frame signatures are evaluated only on candidate +eight recognized game frame signatures are evaluated only on candidate plaintext TCP streams. Do not add pinning or anti-cheat bypasses. ## Module and object boundaries @@ -192,10 +202,11 @@ buttons. non-exported `OnboardingActivity`. The five-page guide may be finished or skipped, and writes completion only at that explicit exit. Its English/Korean segmented control persists the same language preference used by Settings. -Complete backup settings schema v2 includes language, theme, and onboarding -completion; schema-v1 backups remain accepted with System theme and completed -onboarding defaults so an experienced restoring user is not trapped in the -guide. +Complete backup settings schema v4 includes language, theme, onboarding +completion, the server-region reset preset, and the persisted manual game +timezone. Schema-v1 through schema-v3 backups remain accepted with safe +defaults and completed onboarding defaults so an experienced restoring user is +not trapped in the guide. The design deliberately favors composition over deep inheritance. Abstraction and polymorphism appear at real variation points (`GameData`, `ParseEvent`, and diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 30153a6..410bde2 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -32,8 +32,11 @@ rules below take precedence over stale copied dates in archived templates. - Gunsmoke runs for one week followed by two off weeks. The verified cycle anchor is Sunday, 2026-07-19 at 05:00. Other verified starts are 2026-02-22, 2026-03-15, 2026-04-05, 2026-04-26, 2026-05-17, 2026-06-07, and 2026-06-28. -- Period calculations use the configured game timezone. The initial default is - the Android device timezone; stored capture instants remain UTC. +- Period calculations use the selected server region's fixed reset-zone offset. + The known Darkwinter Global/China and HaoPlay Global/Japan/Korea/Asia presets + all reset at 05:00 server time, and Settings converts the next reset to the + Android device timezone for display. Manual mode uses the selected game + timezone, initially the phone timezone. Stored capture instants remain UTC. ## Merit calculation @@ -141,6 +144,12 @@ transitions. never deletes the member or prior snapshots. - A returning UID opens a new membership period. All previous join/withdraw periods remain available. +- Manual edits are accepted only when every period ends after it starts, no two + fully dated periods overlap, and at most one period remains open. An absent + snapshot-derived start remains unknown rather than being treated as an + infinitely old boundary. Current active state is + derived from that open period inside the same transaction; schema-v13 upgrade + repairs any stale derived flag left by an older installation. - A roster CSV imported after newer captures still contributes historical presence spans. The replay creates missing inactive members and inferred withdrawal/rejoin periods without replacing manual or exact Updates evidence. @@ -163,6 +172,21 @@ transitions. - Membership-period numbers are assigned by join date from oldest to newest and are recalculated after manual additions or edits. +## Weekly history consistency + +- Each stored history item is a complete immutable weekly-table revision. The + report cells, displayed membership events, weekly notes, names, and private + notes are encoded together rather than combining an older table with current + member data. +- Every revision passes deterministic structural checks before persistence and + after decoding: seven contiguous days, unique member UIDs, matching cell + dates and report mode, in-period notes, and valid contextual identities. +- Changing the configured game timezone clears incompatible derived history + and rebuilds it from the unchanged UTC evidence using the new 05:00 boundary. +- Weekly history is a derived audit trail, not the primary store. If refreshing + one revision fails after a successful data mutation, the affected restored + selection is cleared so the live database projection remains authoritative. + ## Persistence and privacy Structured snapshots, timestamped activity facts, members, membership periods, events, diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt index e7dfada..ac3c21f 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt @@ -14,6 +14,7 @@ import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.PlatoonActivityData import dev.gf2log.protocol.model.PlatoonActivityEntry import dev.gf2log.protocol.model.PlatoonActivitySummary +import dev.gf2log.protocol.model.PlatoonProfileData import dev.gf2log.protocol.model.PlatoonUpdateEntry import dev.gf2log.protocol.model.PlatoonUpdateMember import dev.gf2log.protocol.model.PlatoonUpdatesData @@ -24,6 +25,7 @@ object Gfl2PayloadDecoder { const val TYPE_WEAPONS = 11021 const val TYPE_ATTACHMENTS = 11061 const val TYPE_COMMON_KEYS = 11138 + const val TYPE_PLATOON_PROFILE = 21905 const val TYPE_GUILD_MEMBERS = 21917 const val TYPE_PLATOON_ACTIVITY = 21935 const val TYPE_PLATOON_UPDATES = 21960 @@ -33,6 +35,7 @@ object Gfl2PayloadDecoder { TYPE_WEAPONS, TYPE_ATTACHMENTS, TYPE_COMMON_KEYS, + TYPE_PLATOON_PROFILE, TYPE_GUILD_MEMBERS, TYPE_PLATOON_ACTIVITY, TYPE_PLATOON_UPDATES, @@ -44,6 +47,7 @@ object Gfl2PayloadDecoder { TYPE_WEAPONS -> decodeWeapons(ProtoReader(bytes)) TYPE_ATTACHMENTS -> decodeAttachments(ProtoReader(bytes)) TYPE_COMMON_KEYS -> decodeCommonKeys(ProtoReader(bytes)) + TYPE_PLATOON_PROFILE -> decodePlatoonProfile(ProtoReader(bytes)) TYPE_GUILD_MEMBERS -> decodeGuildMembers(ProtoReader(bytes)) TYPE_PLATOON_ACTIVITY -> decodePlatoonActivity(ProtoReader(bytes)) TYPE_PLATOON_UPDATES -> decodePlatoonUpdates(ProtoReader(bytes)) @@ -166,6 +170,50 @@ object Gfl2PayloadDecoder { return CommonKey(uid, keyId) } + /** + * Decodes only stable identity and compact emblem fields from payload 21905. + * Free-form notices are skipped because they are not needed for routing. + */ + private fun decodePlatoonProfile(reader: ProtoReader): PlatoonProfileData { + var profile = PlatoonProfileData(0u, "", emptyList(), emptyList()) + while (!reader.exhausted) { + val field = reader.nextField() ?: break + if (field.number == 1) { + profile = decodePlatoonProfileBody(reader.readMessage(field)) + } else { + reader.skip(field) + } + } + return profile + } + + private fun decodePlatoonProfileBody(reader: ProtoReader): PlatoonProfileData { + var platoonId = 0u + var platoonName = "" + val emblemPrimary = mutableListOf() + val emblemSecondary = mutableListOf() + while (!reader.exhausted) { + val field = reader.nextField() ?: break + when (field.number) { + 1 -> platoonId = reader.readUInt(field).toUInt() + 2 -> platoonName = reader.readString(field).take(MAX_PLATOON_NAME_CHARS) + 15 -> emblemPrimary += reader.readRepeatedUInt(field) + .take(MAX_EMBLEM_PARTS) + .map(ULong::toUInt) + 16 -> emblemSecondary += reader.readRepeatedUInt(field) + .take(MAX_EMBLEM_PARTS) + .map(ULong::toUInt) + else -> reader.skip(field) + } + } + return PlatoonProfileData( + platoonId = platoonId, + platoonName = platoonName, + emblemPrimary = emblemPrimary.take(MAX_EMBLEM_PARTS), + emblemSecondary = emblemSecondary.take(MAX_EMBLEM_PARTS), + ) + } + private fun decodeGuildMembers(reader: ProtoReader): GuildMembersData { val members = mutableListOf() while (!reader.exhausted) { @@ -387,4 +435,7 @@ object Gfl2PayloadDecoder { return Doll(dollId, weaponUid, attachmentUids, fixedKeyIds, expansionKeyIds, commonKeyUids) } + + private const val MAX_PLATOON_NAME_CHARS = 128 + private const val MAX_EMBLEM_PARTS = 16 } diff --git a/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt b/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt index 933cfbb..ee15f95 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt @@ -6,6 +6,7 @@ import dev.gf2log.protocol.model.FormationsData import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonActivityData +import dev.gf2log.protocol.model.PlatoonProfileData import dev.gf2log.protocol.model.PlatoonUpdatesData import dev.gf2log.protocol.model.WeaponsData @@ -17,6 +18,17 @@ object ParsedPayloadTextFormatter { appendLine() when (val data = payload.data) { + is PlatoonProfileData -> { + appendLine("platoonId,platoonName,emblemPrimary,emblemSecondary") + appendLine( + listOf( + data.platoonId, + CsvCell.escape(data.platoonName), + data.emblemPrimary.joinToString("|"), + data.emblemSecondary.joinToString("|"), + ).joinToString(","), + ) + } is GuildMembersData -> { appendLine(GuildMembersCsv.HEADER) data.members.forEach { diff --git a/protocol/src/main/java/dev/gf2log/protocol/PayloadCatalog.kt b/protocol/src/main/java/dev/gf2log/protocol/PayloadCatalog.kt index 7b959dd..19abd92 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/PayloadCatalog.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/PayloadCatalog.kt @@ -10,6 +10,13 @@ data class PayloadCategory( object PayloadCatalog { val categories: List = listOf( + PayloadCategory( + payloadType = Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, + name = "PLATOON PROFILE", + tag = "PROFILE", + description = "Platoon ID, name, and emblem/style identifiers used to isolate records.", + isRequired = true, + ), PayloadCategory( payloadType = Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, name = "PLATOON MEMBERS", diff --git a/protocol/src/main/java/dev/gf2log/protocol/model/GameData.kt b/protocol/src/main/java/dev/gf2log/protocol/model/GameData.kt index 31e2c54..e23b622 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/model/GameData.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/model/GameData.kt @@ -32,6 +32,14 @@ data class CommonKey( data class GuildMembersData(val members: List) : GameData +/** Stable identity carried ahead of roster and activity data on a platoon flow. */ +data class PlatoonProfileData( + val platoonId: UInt, + val platoonName: String, + val emblemPrimary: List, + val emblemSecondary: List, +) : GameData + data class GuildMember( val uid: UInt, val name: String, diff --git a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt index e00efb8..3db89eb 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt @@ -6,6 +6,7 @@ import dev.gf2log.protocol.model.FormationsData import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.ParseEvent import dev.gf2log.protocol.model.PlatoonActivityData +import dev.gf2log.protocol.model.PlatoonProfileData import dev.gf2log.protocol.model.PlatoonUpdatesData import dev.gf2log.protocol.model.WeaponsData import org.junit.Assert.assertEquals @@ -132,6 +133,31 @@ class Gfl2StreamParserTest { assertEquals(listOf(1u, 1u), entry.members.map { it.role }) } + @Test + fun capturedPlatoonProfilePreservesStableIdentityAndEmblemParts() { + val bytes = hex( + "0aa30208b99a061205486f726e79181920e4de20325d72656a6563742073616e6974792c" + + "20656d627261636520686f726e792e0a0a446f6e277420666f7267657420746f20646f20" + + "796f75722047756e736d6f6b65206869747320616674657220636c616e6b696e67207468" + + "6520646f6c6c73380e4801500358c2e9a3c40662910141696d696e6720666f7220546f70" + + "20352520647572696e672047756e736d6f6b6520666f7220616c6c207265776172647321" + + "20546f70203130252069732066696e6520746f6f2e0a0a52656372756974696e67206d65" + + "6d626572732077686f2077696c6c20686974207477696365207065722064617920647572" + + "696e672047756e736d6f6b652046726f6e746c696e652e68017a0302010a820106140e0b" + + "110f1288010110914e2814", + ) + + val profile = Gfl2PayloadDecoder.decode( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, + bytes, + ) as PlatoonProfileData + + assertEquals(101_689u, profile.platoonId) + assertEquals("Horny", profile.platoonName) + assertEquals(listOf(2u, 1u, 10u), profile.emblemPrimary) + assertEquals(listOf(20u, 14u, 11u, 17u, 15u, 18u), profile.emblemSecondary) + } + @Test fun oversizedBufferedInputIsRejectedAndStateIsReset() { val parser = Gfl2StreamParser(maximumBufferedBytes = 64) @@ -399,6 +425,10 @@ class Gfl2StreamParserTest { private fun List.singlePayload(): ParseEvent.Payload = filterIsInstance().single() + private fun hex(value: String): ByteArray = value.chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + private fun outerMessage(messageId: Int, vararg payloads: ByteArray): ByteArray = outerMessageWithBody(messageId, payloads.fold(ByteArray(0)) { result, payload -> result + payload }) diff --git a/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt b/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt index 8b9069e..4b37dc6 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt @@ -12,6 +12,7 @@ class PayloadCatalogTest { assertEquals("WEAPONS", PayloadCatalog.tag(11021)) assertEquals("ATTACHMENTS", PayloadCatalog.tag(11061)) assertEquals("KEYS", PayloadCatalog.tag(11138)) + assertEquals("PROFILE", PayloadCatalog.tag(21905)) assertEquals("PLATOON", PayloadCatalog.tag(21917)) assertEquals("ACTIVITY", PayloadCatalog.tag(21935)) assertEquals("UPDATES", PayloadCatalog.tag(21960)) @@ -23,6 +24,7 @@ class PayloadCatalogTest { val required = PayloadCatalog.categories.filter(PayloadCategory::isRequired) assertEquals( setOf( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES,