Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions README_KR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 파일을 만듭니다.

Expand Down
10 changes: 9 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,22 @@ 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
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,
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Any>(TARGET_UID, "Target", 60, 0, 1_000L, 1_000L),
)
writable.execSQL(
"INSERT INTO membership_periods(" +
"uid, joined_at, joined_precision, joined_source" +
") VALUES(?, ?, ?, ?)",
arrayOf<Any>(
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")
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
33 changes: 33 additions & 0 deletions app/src/main/cpp/gf2capture.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -502,13 +529,19 @@ 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");
(*env)->DeleteLocalRef(env, service_class);
(*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)) {
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/dev/gf2log/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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),
Expand Down
Loading
Loading