diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a9454cc..8b3a2a1 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -42,11 +42,13 @@ jobs: - name: Test, lint, and build unsigned verification release run: > ./gradlew - :protocol:testDebugUnitTest + :protocol:test :app:testDebugUnitTest :app:lintDebug :app:assembleRelease -PallowUnsignedRelease=true + -x :app:lintVitalRelease + -x :app:lintVitalAnalyzeRelease --no-daemon --stacktrace diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3d0ad98..8366341 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -107,7 +107,7 @@ jobs: run: > ./gradlew :app:compileDebugKotlin - :protocol:compileDebugKotlin + :protocol:compileKotlin --init-script .github/codeql/codeql-kotlin-compat.init.gradle --rerun-tasks --no-build-cache diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 097d5a5..dd39105 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -3,6 +3,11 @@ name: Deploy project page to GitHub Pages on: push: branches: ["main"] + # The page is built from the READMEs alone. + paths: + - README.md + - README_KR.md + - .github/workflows/deploy-pages.yml workflow_dispatch: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index d4657b4..87ef429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,74 @@ All notable changes to mobileGF2logger are documented here. +## 2.7.1 - 2026-10-25 + +### Fixed + +- Stop a scheduled recording from turning the screen off again about ten + minutes after it finished, while the phone is in use. +- Keep every day of a Gunsmoke Frontline week filled when it is recorded once a + day before the reset: when the exact search gives up, the rest of the week now + continues with safe lower bounds and exact weekly totals instead of dropping + later days. +- Treat a capture at exactly the Sunday 05:00 reset as the closing evidence of + the ending week, keep Standard days at or below the 90-point daily cap, credit + a mid-week joiner's merit from the day after they were last absent, and leave + an open final Saturday's patrol unknown instead of missed. +- Show roster comparison deltas across a weekly reset as lower bounds; the + comparison CSV gains two certainty columns. +- Report capture as stopped only after its teardown finishes, so backup, + restore, CSV import and undo, and Platoon changes started with "Stop capture + and continue" never overlap the last packets; Home shows "Stopping capture" + meanwhile. +- Refuse a CSV undo when newer data arrived after its check, recover + interrupted CSV imports at startup and before capture, roll back a failed + restore under the same lock, and never crash at launch over a recovery. +- Show capture notifications and background messages in the app's chosen + language. +- Reject roster CSV files dated in the future. +- Exclude the management database from Android cloud backup and device + transfer, as documented. +- Ignore Discard while weekly edits are saving, and save exactly the edits that + were tapped. +- Keep member, order, comparison, cutline and history screens open, with their + unsaved edits, when only the Platoon's name or banner changes. +- Avoid a crash on phones without the battery-optimization dialog and an empty + server choice after VPN consent when entering standby mode. +- Close the game from Recents reliably on launchers whose Recents opens slowly. +- Start every automatic recording with a fresh game: a client left in the + background from earlier is closed from Recents first, since resuming it can + stop at a "reconnection failed" notice no automatic tap may press. +- Refresh the Platoon and Weekly screens when a roster is saved while they are + open, keep their scroll position across recreation, and clear stale rows after + a failed load. +- Keep the new-Platoon prompt open while a confirmation runs, show why it + failed, and move it to the screen that just opened; a retried confirmation no + longer duplicates history files. +- Declare the capture VPN unmetered, clean up unfinished roster files, bound + stored diagnostics, and tell TLS from game traffic only by a full record + header. +- Date an inferred join or withdrawal from activity only between the rosters + around the change, order weekly history by recording order instead of the + clock, keep a crash-left journal with a restore's rollback copy, and never + delete a Platoon that was registered again while its deletion was queued. +- Save weekly cutlines off the main thread, report a failed packet deletion, + refuse clipboard copies over the system limit, show only the start of huge raw + packets, and ask before removing the Discord webhook (kept through passing + Keystore errors). +- Keep top and bottom bars at full height under the system bars, ellipsize long + capture notices, and finish pending Platoon deletions off the main thread. + +### Changed + +- Shrink the release APK from 3.2 MB to 1.1 MB: the Platoon emblem and launcher + artwork ship as lossless WebP at the largest size they are drawn, legacy + launcher PNGs and unused metadata are gone, and v1 JAR signing is off. +- Build the protocol module as a plain Kotlin library and skip the duplicate + release lint pass in CI. +- Reuse one scan of the Platoon's weeks for member edits, and remove dead + weekly-note and render bookkeeping paths. + ## 2.7.0 - 2026-10-25 ### Added diff --git a/PRODUCT.md b/PRODUCT.md index 9933120..d9fd696 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -65,7 +65,7 @@ is the binding visual direction for the v2.3.0 frontend: feature guidance, visible progress, top-right Skip, and bottom navigation. The shipped emblem remains the existing launcher artwork at -`app/src/main/res/drawable-nodpi/ic_launcher_art.png`. +`app/src/main/res/drawable-nodpi/ic_launcher_art.webp`. ## Design principles diff --git a/app/build.gradle b/app/build.gradle index 617142f..0b817b0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,8 +154,8 @@ android { applicationId = 'dev.gf2log' minSdk = 26 targetSdk = 36 - versionCode = 20700 - versionName = '2.7.0' + versionCode = 20701 + versionName = '2.7.1' testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner' ndk { @@ -178,7 +178,8 @@ android { storePassword = releaseSigningProperties.getProperty('storePassword') keyAlias = releaseSigningProperties.getProperty('keyAlias') keyPassword = releaseSigningProperties.getProperty('keyPassword') - enableV1Signing = true + // minSdk 26 verifies v2+ signatures; the JAR (v1) signature only adds bytes. + enableV1Signing = false enableV2Signing = true enableV3Signing = true enableV4Signing = true @@ -209,6 +210,19 @@ android { } } + packaging { + resources { + // Kotlin reflection metadata and library version/licence stamps; nothing reads them at + // run time (no kotlin-reflect, no coroutines debug probes). + excludes += [ + '**/*.kotlin_builtins', + 'DebugProbesKt.bin', + 'META-INF/*.version', + 'META-INF/androidx/**/LICENSE.txt', + ] + } + } + bundle { language { enableSplit = false 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 6551579..38825a8 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -26,6 +26,7 @@ import dev.gf2log.protocol.model.GuildMember import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.EOFException +import java.io.File import java.io.InputStream import java.time.Instant import java.time.LocalDate @@ -314,6 +315,62 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(FilePaths.preRestoreDatabase(context).exists()) } + @Test + fun failedRestoreKeepsTheWalACrashLeftBesideTheCurrentDatabase() { + seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") + val archive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).exportFull(it) + }.toByteArray() + replaceDatabaseWithCurrentState() + settingsStore.replace(currentSettings()) + val databaseFile = context.getDatabasePath(storageScope.databaseName) + val crashCopy = File(context.cacheDir, "crash-left-database").apply { + deleteRecursively() + check(mkdirs()) + } + PlatoonRepository.withExclusiveDatabase(storageScope) { + // Copying the files while the WAL connection is open leaves the committed update + // only in the WAL, as a process death before the next checkpoint would. + SQLiteDatabase.openDatabase( + databaseFile.path, + null, + SQLiteDatabase.OPEN_READWRITE or SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING, + ).use { db -> + db.rawQuery("PRAGMA wal_autocheckpoint = 0", null).use { it.moveToFirst() } + db.execSQL( + "UPDATE members SET note = ? WHERE uid = ?", + arrayOf(CRASH_LEFT_NOTE, CURRENT_UID), + ) + databaseFile.copyTo(File(crashCopy, "database")) + File(databaseFile.path + "-wal").copyTo(File(crashCopy, "wal")) + } + assertTrue(context.deleteDatabase(storageScope.databaseName)) + File(crashCopy, "database").copyTo(databaseFile) + File(crashCopy, "wal").copyTo(File(databaseFile.path + "-wal")) + } + crashCopy.deleteRecursively() + + assertThrows(IllegalStateException::class.java) { + PlatoonBackupManager( + context = context, + settingsStore = settingsStore, + restoreObserver = { checkpoint -> + if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { + error("simulated restore failure") + } + }, + ).restoreFull(ByteArrayInputStream(archive)) + } + + PlatoonDatabase(context).use { database -> + val db = database.readableDatabase + assertEquals(1L, count(db, "members", "note = ?", CRASH_LEFT_NOTE)) + assertEquals(0L, count(db, "members", "uid = ?", ARCHIVED_UID)) + } + assertFalse(FilePaths.preRestoreDatabase(context).exists()) + assertFalse(File(FilePaths.preRestoreDatabase(context).path + "-wal").exists()) + } + @Test fun freshInstallFailureRestoresTheMissingDatabaseState() { seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") @@ -686,6 +743,57 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(FilePaths.csvCheckpointDirectory(context).exists()) } + @Test + fun checkpointInstallRefusesADatabaseThatChangedSinceTheUndoCheck() { + seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") + val checkpoint = ByteArrayOutputStream().also { output -> + PlatoonBackupManager(context, storageScope).export(output) + }.toByteArray() + val manager = PlatoonBackupManager(context, storageScope) + val checkedDigest = manager.currentDatabaseSha256() + // A capture ingests after the undo's own digest check but before the install. + replaceDatabaseForCheckpoint(CURRENT_UID, "Newer capture", "newer-capture.csv") + + assertThrows(IllegalStateException::class.java) { + manager.restoreCheckpoint(ByteArrayInputStream(checkpoint), checkedDigest) + } + + PlatoonDatabase(context).use { database -> + assertEquals(1L, count(database.readableDatabase, "members", "uid = ?", CURRENT_UID)) + assertEquals(0L, count(database.readableDatabase, "members", "uid = ?", ARCHIVED_UID)) + } + assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) + } + + @Test + fun startupRecoveryFinishesInterruptedImportsAndSurvivesABrokenScope() { + seedDatabase(ARCHIVED_UID, "Before interrupted import", "before-interrupted-import.csv") + val checkpoint = FilePaths.csvCheckpointDirectory(context) + assertTrue(checkpoint.mkdirs()) + java.io.FileOutputStream(java.io.File(checkpoint, "platoon.gf2backup")).use { output -> + PlatoonBackupManager(context).export(output) + } + java.io.File(checkpoint, "planned-files.txt").writeText("\n", Charsets.UTF_8) + replaceDatabaseForCheckpoint(CURRENT_UID, "Interrupted import", "interrupted-import.csv") + val brokenScope = PlatoonStorageScope( + PlatoonProfileRegistry(context).upsertDetected( + SupportedGamePackages.DARKWINTER, + GameServerRegion.DARKWINTER_GLOBAL, + dev.gf2log.protocol.model.PlatoonProfileData(303u, "Broken checkpoint", 0u, 0u), + ).storageId, + ) + // A checkpoint without its archive cannot be recovered and must not stop the others. + assertTrue(java.io.File(brokenScope.rootDirectory(context), "csv-import-checkpoint").mkdirs()) + + assertEquals(1, CsvImportCheckpointManager.recoverInterruptedImports(context)) + + PlatoonDatabase(context).use { database -> + assertEquals(1L, count(database.readableDatabase, "members", "uid = ?", ARCHIVED_UID)) + assertEquals(0L, count(database.readableDatabase, "members", "uid = ?", CURRENT_UID)) + } + assertFalse(checkpoint.exists()) + } + @Test fun malformedCompleteBackupDoesNotMutateExistingState() { seedDatabase(CURRENT_UID, "Current member", "current-source.csv") @@ -1182,6 +1290,7 @@ class PlatoonBackupManagerIntegrationTest { const val CURRENT_UID = 2002L const val THIRD_UID = 3003L const val ARCHIVED_NOTE = "Weekly review" + const val CRASH_LEFT_NOTE = "Committed only to the WAL" const val INVALID_TARGET_PACKAGE = "not a package" val PERIOD_START: LocalDate = LocalDate.of(2026, 7, 26) } 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 018a77b..d64e26e 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -71,17 +71,6 @@ class PlatoonDatabaseIntegrationTest { put("game_day", 20665L) }, ) - writable.insertOrThrow( - "weekly_notes", - null, - ContentValues().apply { - put("period_start", 20665L) - put("game_day", 20665L) - put("text", "Target joined") - put("event_id", targetEventId) - put("is_automatic", 1) - }, - ) writable.insertOrThrow( "platoon_activity", null, @@ -106,7 +95,6 @@ class PlatoonDatabaseIntegrationTest { assertEquals(0L, count("snapshot_members", "uid = ?", TARGET_UID)) assertEquals(0L, count("weekly_overrides", "uid = ?", TARGET_UID)) assertEquals(0L, count("platoon_activity", "resolved_uid = ?", TARGET_UID)) - assertEquals(0L, count("weekly_notes", "text = ?", "Target joined")) assertEquals(1L, count("members", "uid = ?", OTHER_UID)) assertEquals(1L, count("snapshots", "source_file = ?", sourceFile)) assertTrue(sourceFile in database.snapshotSourceFiles()) @@ -1269,6 +1257,87 @@ class PlatoonDatabaseIntegrationTest { assertFalse(database.listWeeklyReportHistory(period).any { it.active }) } + @Test + fun onlyActivityBeforeTheRevealingRosterDatesAnInferredJoin() { + val previousRoster = Instant.parse("2026-08-03T00:00:00Z") + val revealingRoster = Instant.parse("2026-08-03T06:00:00Z") + listOf( + previousRoster to listOf(member(OTHER_UID, "Other")), + revealingRoster to listOf(member(OTHER_UID, "Other"), member(TARGET_UID, "Target")), + ).forEach { (capturedAt, members) -> + database.ingestSnapshot( + PlatoonSnapshot(id = 0, capturedAt = capturedAt, members = members), + EvidenceSource.SNAPSHOT, + ) + } + val inferredJoin = "uid = ? AND joined_precision = 'INFERRED' AND joined_at = " + + revealingRoster.toEpochMilli() + + // Within the old symmetric 12-hour window, but after the roster already listed Target. + database.ingestPlatoonActivity( + listOf(activity("Target", revealingRoster.plusSeconds(60L * 60L))), + revealingRoster.plusSeconds(2L * 60L * 60L), + ) + assertEquals(1L, count("membership_periods", inferredJoin, TARGET_UID)) + + val joinedAt = revealingRoster.minusSeconds(60L * 60L) + database.ingestPlatoonActivity( + listOf(activity("Target", joinedAt)), + revealingRoster.plusSeconds(3L * 60L * 60L), + ) + assertEquals(0L, count("membership_periods", inferredJoin, TARGET_UID)) + assertEquals( + 1L, + count( + "membership_periods", + "uid = ? AND joined_precision = 'EXACT' AND joined_at = " + joinedAt.toEpochMilli(), + TARGET_UID, + ), + ) + } + + private fun activity(name: String, occurredAt: Instant) = PlatoonActivityObservation( + occurredAt = occurredAt, + actionId = 701001L, + kind = 1, + memberName = name, + ) + + @Test + fun weeklyHistoryKeepsRecordingOrderAfterTheClockStepsBackwards() { + val period = LocalDate.of(2026, 8, 16).toEpochDay() + repeat(PlatoonDatabase.MAX_WEEKLY_REPORT_HISTORY) { index -> + database.recordWeeklyReportHistory( + periodStartEpochDay = period, + recordedAt = Instant.ofEpochMilli(1_000_000L + index), + fingerprint = index.toString(16).padStart(64, '0'), + payload = byteArrayOf(index.toByte()), + clearActiveOnChange = true, + ) + } + val steppedBack = database.recordWeeklyReportHistory( + periodStartEpochDay = period, + recordedAt = Instant.ofEpochMilli(1L), + fingerprint = "a".repeat(64), + payload = byteArrayOf(1), + clearActiveOnChange = true, + ) + + val retained = database.listWeeklyReportHistory(period) + assertEquals(PlatoonDatabase.MAX_WEEKLY_REPORT_HISTORY, retained.size) + assertEquals(steppedBack, retained.first().id) + assertEquals( + steppedBack, + database.recordWeeklyReportHistory( + periodStartEpochDay = period, + recordedAt = Instant.ofEpochMilli(2L), + fingerprint = "a".repeat(64), + payload = byteArrayOf(1), + clearActiveOnChange = true, + ), + ) + } + @Test fun failedAtomicHistoryReplacementPreservesEveryPreviousRevision() { val originalPeriod = LocalDate.of(2026, 8, 16).toEpochDay() diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt index 4340c34..393c649 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt @@ -566,6 +566,68 @@ class PlatoonProfileRegistryIntegrationTest { assertEquals(listOf(kept.storageId), registry.list().map(PlatoonProfile::storageId)) } + @Test + fun redetectingAPlatoonWhoseDeletionIsStillQueuedGetsAFreshScope() { + val queuedId = PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 7L, + ) + queueDeletion(queuedId) + val registry = PlatoonProfileRegistry(context) + + val redetected = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(7u, "Redetected", 0u, 0u), + ) + val scope = PlatoonStorageScope(redetected.storageId) + PlatoonRepository(context, scope).ingest( + Instant.parse("2026-08-24T12:00:00Z"), + listOf(member(1u, "Member")), + "redetected.csv", + ) + + assertNotEquals(queuedId, redetected.storageId) + assertEquals(listOf(redetected.storageId), registry.list().map(PlatoonProfile::storageId)) + assertTrue(context.getDatabasePath(scope.databaseName).exists()) + assertFalse(PlatoonProfileAdministration.isPendingDeletion(context, queuedId)) + } + + @Test + fun aRestoredScopeLeavesTheDeletionQueueInsteadOfBeingDeleted() { + val registry = PlatoonProfileRegistry(context) + val profile = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(8u, "Restored", 0u, 0u), + ) + val scope = PlatoonStorageScope(profile.storageId) + PlatoonRepository(context, scope).ingest( + Instant.parse("2026-08-24T12:00:00Z"), + listOf(member(1u, "Member")), + "restored.csv", + ) + assertTrue(registry.forget(profile.storageId)) + queueDeletion(profile.storageId) + PlatoonRepository.withExclusiveDatabase(scope) { registry.upsertRestored(profile) } + + assertEquals(listOf(profile.storageId), registry.list().map(PlatoonProfile::storageId)) + assertTrue(context.getDatabasePath(scope.databaseName).exists()) + assertFalse(PlatoonProfileAdministration.isPendingDeletion(context, profile.storageId)) + assertEquals( + "Member", + PlatoonRepository(context, scope).listMemberStatuses().single().name, + ) + } + + private fun queueDeletion(storageId: String) { + check( + context.getSharedPreferences("platoon_profile_deletions", android.content.Context.MODE_PRIVATE) + .edit().putStringSet("pending_storage_ids", setOf(storageId)).commit(), + ) + } + @Test fun registryRejectsUnboundedNewProfilesAndCanRemoveInactiveMetadata() { val registry = PlatoonProfileRegistry(context) diff --git a/app/src/main/cpp/gf2capture.c b/app/src/main/cpp/gf2capture.c index bcb10ff..15795e1 100644 --- a/app/src/main/cpp/gf2capture.c +++ b/app/src/main/cpp/gf2capture.c @@ -49,6 +49,7 @@ typedef struct capture_context { jmethodID stopped_method; pthread_t thread; atomic_bool running; + int attach_state; // guarded by attach_mutex int tun_fd; uint64_t sent_bytes; uint64_t received_bytes; @@ -57,6 +58,13 @@ typedef struct capture_context { static pthread_mutex_t context_mutex = PTHREAD_MUTEX_INITIALIZER; static capture_context_t *active_context = NULL; +// A thread that cannot attach to the JVM cannot report its own stop, so nativeStart waits for the +// attach outcome and fails the start instead of leaving the service RUNNING on a dead loop. +#define ATTACH_PENDING 0 +#define ATTACH_OK 1 +#define ATTACH_FAILED 2 +static pthread_mutex_t attach_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t attach_cond = PTHREAD_COND_INITIALIZER; // Process-wide so a flow ID is never reused by a later capture: unconfirmed Platoon candidates // outlive the capture that observed them and refer to their flows by ID. static _Atomic int64_t last_flow_id = 0; @@ -80,19 +88,43 @@ static bool starts_with(const char *payload, uint16_t length, const char *prefix return length >= prefix_length && memcmp(payload, prefix, prefix_length) == 0; } +// Every TLS/SSL 3.0 record header: content type 20-23, major version 3, minor version 0-4, and a +// length within the largest legal record (2^14 plus the 2048-byte TLS 1.2 expansion allowance). +// A GF2 frame header whose message ID happens to start with the same two bytes must also match +// the third byte and a plausible length before inspection of its flow is disabled. A first +// segment too short to carry the length still counts as TLS, as before. +#define TLS_RECORD_HEADER_LENGTH 5 +#define TLS_MAX_RECORD_LENGTH (16384U + 2048U) + +static bool is_tls_record_header(const char *payload, uint16_t length) { + if (length < 3) { + return false; + } + uint8_t record_type = (uint8_t) payload[0]; + if (record_type < 0x14U || record_type > 0x17U || + (uint8_t) payload[1] != 0x03U || (uint8_t) payload[2] > 0x04U) { + return false; + } + if (length < TLS_RECORD_HEADER_LENGTH) { + return true; + } + uint32_t record_length = ((uint32_t) (uint8_t) payload[3] << 8) | (uint8_t) payload[4]; + return record_length > 0 && record_length <= TLS_MAX_RECORD_LENGTH; +} + static bool is_web_transport(const char *payload, uint16_t length) { - if (length >= 3) { - uint8_t record_type = (uint8_t) payload[0]; - if (record_type >= 0x14U && record_type <= 0x17U && - (uint8_t) payload[1] == 0x03U) { - return true; - } + if (is_tls_record_header(payload, length)) { + return true; } return starts_with(payload, length, "GET ") || starts_with(payload, length, "POST ") || starts_with(payload, length, "HEAD ") || starts_with(payload, length, "PUT ") || + starts_with(payload, length, "OPTIONS ") || + starts_with(payload, length, "DELETE ") || + starts_with(payload, length, "PATCH ") || + starts_with(payload, length, "CONNECT ") || starts_with(payload, length, "HTTP/") || starts_with(payload, length, "PRI * HTTP/2.0"); } @@ -397,13 +429,23 @@ static void reap_context(JNIEnv *env, capture_context_t *context) { free(context); } +static void publish_attach_state(capture_context_t *context, int state) { + pthread_mutex_lock(&attach_mutex); + context->attach_state = state; + pthread_cond_broadcast(&attach_cond); + pthread_mutex_unlock(&attach_mutex); +} + static void *capture_thread_main(void *argument) { capture_context_t *context = (capture_context_t *) argument; if ((*context->vm)->AttachCurrentThread(context->vm, &context->env, NULL) != JNI_OK) { LOGE("Unable to attach capture thread to JVM"); context->env = NULL; + // nativeStart joins this thread before it frees the context. + publish_attach_state(context, ATTACH_FAILED); goto cleanup; } + publish_attach_state(context, ATTACH_OK); zdtun_callbacks_t callbacks = { .send_client = send_client_callback, @@ -602,6 +644,21 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( pthread_mutex_unlock(&context_mutex); return JNI_FALSE; } + + pthread_mutex_lock(&attach_mutex); + while (context->attach_state == ATTACH_PENDING) { + pthread_cond_wait(&attach_cond, &attach_mutex); + } + bool attached = context->attach_state == ATTACH_OK; + pthread_mutex_unlock(&attach_mutex); + if (!attached) { + // The unattached thread exits without callbacks; reporting the failed start is the stop. + pthread_join(context->thread, NULL); + release_context(env, context); + free(context); + pthread_mutex_unlock(&context_mutex); + return JNI_FALSE; + } active_context = context; pthread_mutex_unlock(&context_mutex); diff --git a/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt b/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt index 0ec8cd9..3ff7426 100644 --- a/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt +++ b/app/src/main/java/dev/gf2log/app/ActivePlatoonScopeBinding.kt @@ -10,6 +10,13 @@ internal class ActivePlatoonScopeBinding(context: Context) { private val boundProfile = PlatoonProfileRegistry(context).active() val scope: PlatoonStorageScope? = boundProfile?.let { PlatoonStorageScope(it.storageId) } + /** + * Whether the selected Platoon still uses this screen's storage. Screens that do not show the + * profile itself use this, so a renamed Platoon or a new banner never closes them mid-edit. + */ + fun isSameStorage(context: Context): Boolean = + PlatoonProfileRegistry(context).active()?.storageId == boundProfile?.storageId + fun isCurrent(context: Context): Boolean = PlatoonProfileRegistry(context).active()?.let(::visualIdentity) == boundProfile?.let(::visualIdentity) diff --git a/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt b/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt index ff28cb0..ef8363b 100644 --- a/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt +++ b/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt @@ -75,13 +75,23 @@ internal class DiscordWebhookRow(private val activity: Activity, private val wor setTextColor(getColor(R.color.destructive_action)) setOnClickListener { if (work.isBusy(WRITE)) return@setOnClickListener - work.run(key = WRITE, task = { DiscordWebhookSecretStore(activity).clear() }) { result -> - dialog.dismiss() - Feedback.show( - activity, - if (result.isSuccess) R.string.discord_webhook_cleared else R.string.discord_webhook_clear_failed, - ) - refresh() + // The address cannot be shown again once removed, so removal asks first. + Dialogs.confirm( + activity, + R.string.clear_discord_webhook, + R.string.clear_discord_webhook_confirm, + R.string.clear_discord_webhook, + destructive = true, + ) { + if (work.isBusy(WRITE)) return@confirm + work.run(key = WRITE, task = { DiscordWebhookSecretStore(activity).clear() }) { result -> + dialog.dismiss() + Feedback.show( + activity, + if (result.isSuccess) R.string.discord_webhook_cleared else R.string.discord_webhook_clear_failed, + ) + refresh() + } } } } diff --git a/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt b/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt index cb8da56..d6c81de 100644 --- a/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt +++ b/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt @@ -45,7 +45,11 @@ internal class HomeCapturePanel( private val stateTitle = context.text(null, TextRole.HEADLINE) { accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE } - private val stateDetail = context.text(null, TextRole.SECONDARY) { maxLines = 4 } + private val stateDetail = context.text(null, TextRole.SECONDARY) { + maxLines = 4 + // Large font sizes would otherwise cut a notice off mid-word with no sign of it. + ellipsize = android.text.TextUtils.TruncateAt.END + } private val checklist = context.column { visibility = View.GONE } private val captureActions = context.row(Gravity.TOP) private val clients = context.column() @@ -104,6 +108,7 @@ internal class HomeCapturePanel( CapturePhase.STOPPED -> R.string.capture_ready to R.color.text_primary CapturePhase.STARTING -> R.string.status_preparing to R.color.accent_text CapturePhase.RUNNING -> R.string.capture_running to R.color.success_text + CapturePhase.STOPPING -> R.string.capture_stopping to R.color.text_secondary } stateTitle.text = getString(title) stateTitle.setTextColor(getColor(color)) @@ -115,7 +120,7 @@ internal class HomeCapturePanel( (stateDot.background as GradientDrawable).setColor( getColor( when (phase) { - CapturePhase.STOPPED -> R.color.outline_strong + CapturePhase.STOPPED, CapturePhase.STOPPING -> R.color.outline_strong CapturePhase.STARTING -> R.color.accent CapturePhase.RUNNING -> R.color.success_mark }, @@ -126,6 +131,7 @@ internal class HomeCapturePanel( notice?.notice == CaptureNotice.CAPTURING -> getString(R.string.capturing_clients, clientNames(snapshot.capturedTargets)) notice != null && notice.notice != CaptureNotice.PREPARING -> notice.format(this) phase == CapturePhase.STOPPED -> getString(R.string.capture_ready_detail) + phase == CapturePhase.STOPPING -> getString(R.string.capture_stopping_detail) else -> getString(R.string.capture_preparing_detail) } stateDetail.setTextColor( @@ -200,7 +206,10 @@ internal class HomeCapturePanel( ) } else { captureActions.addView( - button(getString(R.string.stop_capture), Controls.Role.DESTRUCTIVE, R.drawable.ic_stop) { onStop() }, + button(getString(R.string.stop_capture), Controls.Role.DESTRUCTIVE, R.drawable.ic_stop) { onStop() }.apply { + // Already stopping: nothing more to request until the teardown finishes. + isEnabled = phase != CapturePhase.STOPPING + }, lp(), ) } diff --git a/app/src/main/java/dev/gf2log/app/HomePacketHistory.kt b/app/src/main/java/dev/gf2log/app/HomePacketHistory.kt index 737198b..9e2375e 100644 --- a/app/src/main/java/dev/gf2log/app/HomePacketHistory.kt +++ b/app/src/main/java/dev/gf2log/app/HomePacketHistory.kt @@ -297,15 +297,19 @@ internal class HomePacketHistory( key = WRITE, task = { (if (fromSaved) savedStore?.delete(ids) else historyStore?.delete(ids)) ?: 0 }, ) { result -> - val deleted = result.getOrDefault(0) - Feedback.show( - activity, - activity.resources.getQuantityString( - if (fromSaved) R.plurals.status_deleted_saved_history else R.plurals.status_deleted_history, - deleted, - deleted, - ), - ) + val deleted = result.getOrNull() + if (deleted == null) { + Feedback.show(activity, R.string.history_delete_failed) + } else { + Feedback.show( + activity, + activity.resources.getQuantityString( + if (fromSaved) R.plurals.status_deleted_saved_history else R.plurals.status_deleted_history, + deleted, + deleted, + ), + ) + } setSelecting(false) load() } diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 223bfe8..64e4a72 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -72,7 +72,7 @@ class MainActivity : BaseActivity() { private val vpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> startPending = false - if (result.resultCode == RESULT_OK) startCaptureService() else CaptureStatus.markStopped() + if (result.resultCode == RESULT_OK) startCaptureService() else CaptureStatus.clearIdleNotice() } private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { @@ -276,9 +276,14 @@ class MainActivity : BaseActivity() { work.run( key = SUMMARY, task = { - val snapshots = PlatoonRepository(this, scope).listSnapshots(2) + val repository = PlatoonRepository(this, scope) + val snapshots = repository.listSnapshots(2) val latest = snapshots.firstOrNull() - latest?.let { it to snapshots.getOrNull(1)?.let { older -> SnapshotComparison.compare(older, it) } } + latest?.let { + it to snapshots.getOrNull(1)?.let { older -> + SnapshotComparison.compare(older, it, repository.gameZone()) + } + } }, ) { result -> val summary = result.getOrNull() diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 44b212e..fff0e11 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -90,7 +90,7 @@ class MemberDetailActivity : BaseActivity() { } override fun onScreenResumed() { - if (!profileBinding.isCurrent(this)) { + if (!profileBinding.isSameStorage(this)) { // The member belongs to the previous Platoon scope. finish() return diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index 0ff93b5..b709fa8 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -100,7 +100,7 @@ class MemberOrderActivity : BaseActivity() { } override fun onScreenResumed() { - if (!profileBinding.isCurrent(this)) finish() + if (!profileBinding.isSameStorage(this)) finish() } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/dev/gf2log/app/MobileGF2LoggerApplication.kt b/app/src/main/java/dev/gf2log/app/MobileGF2LoggerApplication.kt index 90e1481..4f64f9a 100644 --- a/app/src/main/java/dev/gf2log/app/MobileGF2LoggerApplication.kt +++ b/app/src/main/java/dev/gf2log/app/MobileGF2LoggerApplication.kt @@ -1,11 +1,31 @@ package dev.gf2log.app import android.app.Application +import android.util.Log +import dev.gf2log.app.management.CsvImportCheckpointManager import dev.gf2log.app.management.PlatoonBackupManager +import dev.gf2log.app.management.PlatoonProfileAdministration class MobileGF2LoggerApplication : Application() { override fun onCreate() { super.onCreate() - PlatoonBackupManager.recoverInterruptedFullRestore(this) + // A recovery that keeps failing must not crash every launch; its durable state stays for + // the next attempt, which also runs whenever that Platoon's repository is opened. + try { + PlatoonBackupManager.recoverInterruptedFullRestore(this) + } catch (error: Exception) { + Log.e(TAG, "Interrupted restore recovery failed", error) + } + // Interrupted CSV imports and undos finish off the main thread. Capture repeats the same + // per-scope recovery before it ingests, and each scope's claim serializes the two. + Thread({ + CsvImportCheckpointManager.recoverInterruptedImports(this) + runCatching { PlatoonProfileAdministration.recoverPending(this) } + .onFailure { Log.e(TAG, "Pending Platoon deletion recovery failed", it) } + }, "GF2StartupRecovery").start() + } + + private companion object { + const val TAG = "GF2Startup" } } diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index bb61c17..70d3060 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -51,7 +51,10 @@ class OptionsActivity : BaseActivity() { } override fun onScreenResumed() { - if (!profileBinding.isCurrent(this)) recreate() + if (!profileBinding.isCurrent(this)) { + recreate() + return + } // Platoons may have changed since the next scheduled recording was planned. if (ScheduledRecording.isEnabled(this)) ScheduledRecording.reschedule(this) } diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index d27a9e2..2f125d6 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -120,7 +120,18 @@ class PacketHistoryActivity : BaseActivity() { private fun rawView(content: String) = ScrollView(this).apply { clipToPadding = false setPadding(dp(16), 0, dp(16), dp(24)) - addView(text(content, TextRole.BODY) { + // One selectable TextView lays out its whole text at once; a huge entry would freeze the + // screen, so only its beginning is shown. + val shown = if (content.length > MAX_RAW_CHARS) { + content.take(MAX_RAW_CHARS) + "\n\n" + getString( + R.string.packet_raw_truncated, + java.text.NumberFormat.getIntegerInstance().format(MAX_RAW_CHARS), + java.text.NumberFormat.getIntegerInstance().format(content.length), + ) + } else { + content + } + addView(text(shown, TextRole.BODY) { typeface = Typeface.MONOSPACE textSize = 12f setTextIsSelectable(true) @@ -156,6 +167,11 @@ class PacketHistoryActivity : BaseActivity() { } private fun copy(content: String) { + // The clipboard travels through a binder call that fails above about 1 MB. + if (content.length > MAX_COPY_CHARS) { + Feedback.show(this, R.string.packet_too_large_to_copy, long = true) + return + } Feedback.copy(this, getString(R.string.clipboard_label), content, R.string.status_packet_copied) } @@ -189,6 +205,9 @@ class PacketHistoryActivity : BaseActivity() { } companion object { + /** About 0.6 MB as UTF-16, safely under the clipboard's binder limit. */ + private const val MAX_COPY_CHARS = 300_000 + private const val MAX_RAW_CHARS = 200_000 const val EXTRA_ENTRY_ID = "entry_id" const val EXTRA_ENTRY_TITLE = "entry_title" const val EXTRA_SAVED_ENTRY = "saved_entry" diff --git a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt index 4c462c4..adb5fec 100644 --- a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt @@ -7,11 +7,7 @@ import android.text.TextUtils import android.view.View import android.widget.RadioButton import android.widget.RadioGroup -import dev.gf2log.app.capture.CaptureNotice -import dev.gf2log.app.capture.CaptureSnapshot -import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.capture.CaptureVpnService -import dev.gf2log.app.capture.format import dev.gf2log.app.capture.PendingPlatoonAdmissionStore import dev.gf2log.app.management.PlatoonClient import dev.gf2log.app.settings.ClientServerRegionPreferences @@ -29,25 +25,41 @@ import java.lang.ref.WeakReference /** * Presents the process-memory admission gate for a newly detected Platoon. Screens observe the * pending store, so the prompt appears as soon as a candidate arrives and closes when it is - * confirmed or discarded elsewhere. + * confirmed or discarded elsewhere. A confirmation keeps the prompt open while it runs, and a + * failed one comes back to it with the reason the store recorded. */ internal object PendingPlatoonAdmissionPrompt { private var visible = WeakReference(null) private var owner = WeakReference(null) private var visibleToken: String? = null + private var refreshVisible: ((PendingPlatoonAdmissionStore.Summary) -> Unit)? = null private val selections = HashMap() + /** + * The candidate's attempt count when a confirmation was sent. Until the worker claims it and + * the count moves on, the request is still queued and an earlier failure is not shown. + */ + private val requested = HashMap() + fun onCandidatesChanged(activity: Activity, candidates: List) { if (activity.isFinishing || activity.isDestroyed) return val showing = visible.get()?.takeIf { it.isShowing } + val current = candidates.firstOrNull { it.token == visibleToken } if (showing != null) { - if (candidates.any { it.token == visibleToken }) return - // The visible candidate was resolved; move on to the next queued one. + if (current != null && owner.get() === activity) { + refreshVisible?.invoke(current) + return + } + // Either the visible candidate was resolved (move on to the next queued one), or a + // newly started screen now owns the prompt: the one behind it closes its dialog on + // stop, so the prompt moves here, keeping the chosen server. showing.dismiss() clearVisible() } - selections.keys.retainAll(candidates.mapTo(mutableSetOf()) { it.token }) - candidates.firstOrNull()?.let { show(activity, it) } + val tokens = candidates.mapTo(mutableSetOf()) { it.token } + selections.keys.retainAll(tokens) + requested.keys.retainAll(tokens) + (current ?: candidates.firstOrNull())?.let { show(activity, it) } } /** Dismisses the prompt only when [activity] owns it, so a newly started screen keeps its own. */ @@ -61,6 +73,7 @@ internal object PendingPlatoonAdmissionPrompt { visible.clear() owner.clear() visibleToken = null + refreshVisible = null } private fun show(activity: Activity, candidate: PendingPlatoonAdmissionStore.Summary) { @@ -93,7 +106,8 @@ internal object PendingPlatoonAdmissionPrompt { addView(status, activity.lp(top = 8)) addView(activity.text(activity.getString(R.string.new_platoon_memory_only_notice), TextRole.CAPTION), activity.lp(top = 8)) } - var statusListener: ((CaptureSnapshot) -> Unit)? = null + var latest = candidate + var render: (() -> Unit)? = null val dialog = Dialogs.show( Dialogs.builder(activity) .setTitle(R.string.new_platoon_detected) @@ -103,6 +117,7 @@ internal object PendingPlatoonAdmissionPrompt { .setPositiveButton(R.string.confirm, null), ) { shown -> val confirm = shown.getButton(AlertDialog.BUTTON_POSITIVE) + val discard = shown.getButton(AlertDialog.BUTTON_NEGATIVE) val ids = regions.map { region -> RadioButton(activity).apply { id = View.generateViewId() @@ -114,44 +129,56 @@ internal object PendingPlatoonAdmissionPrompt { isChecked = selections[candidate.token] == region }.also { radios.addView(it) }.id } - confirm.isEnabled = candidate.token in selections radios.setOnCheckedChangeListener { _, checkedId -> regions.getOrNull(ids.indexOf(checkedId))?.let { selections[candidate.token] = it } - confirm.isEnabled = candidate.token in selections + render?.invoke() } - confirm.setOnClickListener { - val region = selections[candidate.token] ?: return@setOnClickListener - val requestedAt = System.currentTimeMillis() - status.text = activity.getString(R.string.confirming_platoon) - status.setTextColor(activity.getColor(R.color.text_secondary)) - status.visibility = View.VISIBLE - // Failures arrive only as capture notices; surface them here and allow a retry. - statusListener?.let(CaptureStatus::removeListener) - statusListener = { snapshot: CaptureSnapshot -> - val event = snapshot.notice - if (event != null && event.atMillis >= requestedAt && event.notice in ADMISSION_FAILURES) { - status.text = event.format(activity) + render = { + if (requested[candidate.token].let { it != null && it < latest.attempts }) { + requested -= candidate.token + } + val busy = latest.admitting || candidate.token in requested + val failure = latest.failure + when { + busy -> { + status.text = activity.getString(R.string.confirming_platoon) + status.setTextColor(activity.getColor(R.color.text_secondary)) + status.visibility = View.VISIBLE + } + failure != null -> { + status.text = activity.getString(failure.messageRes) status.setTextColor(activity.getColor(R.color.destructive_action)) - confirm.isEnabled = true + status.visibility = View.VISIBLE } - }.also(CaptureStatus::addListener) + else -> status.visibility = View.GONE + } + confirm.isEnabled = !busy && candidate.token in selections + discard.isEnabled = !busy + } + render?.invoke() + confirm.setOnClickListener { + val region = selections[candidate.token] ?: return@setOnClickListener + requested[candidate.token] = latest.attempts + render?.invoke() activity.startService( Intent(activity, CaptureVpnService::class.java) .setAction(CaptureVpnService.ACTION_CONFIRM_PENDING_PLATOON) .putExtra(CaptureVpnService.EXTRA_PENDING_TOKEN, candidate.token) .putExtra(CaptureVpnService.EXTRA_SERVER_REGION, region.storedValue), ) - confirm.isEnabled = false } - shown.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { confirmDiscard(activity, candidate) } + discard.setOnClickListener { confirmDiscard(activity, candidate) } } dialog.setOnDismissListener { - statusListener?.let(CaptureStatus::removeListener) if (visible.get() === dialog) clearVisible() } visible = WeakReference(dialog) owner = WeakReference(activity) visibleToken = candidate.token + refreshVisible = { summary -> + latest = summary + render?.invoke() + } } private fun confirmDiscard(activity: Activity, candidate: PendingPlatoonAdmissionStore.Summary) { @@ -170,12 +197,4 @@ internal object PendingPlatoonAdmissionPrompt { destructive = true, ) } - - private val ADMISSION_FAILURES = setOf( - CaptureNotice.ADMISSION_INVALID, - CaptureNotice.ADMISSION_REGION_MISMATCH, - CaptureNotice.ADMISSION_LIMIT, - CaptureNotice.ADMISSION_PROFILE_FAILED, - CaptureNotice.ADMISSION_REPLAY_FAILED, - ) } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index b8495f4..de5e9ec 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -17,6 +17,7 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import dev.gf2log.app.capture.RosterEvents import dev.gf2log.app.management.MemberStatus import dev.gf2log.app.management.PlatoonMemberCsv import dev.gf2log.app.management.PlatoonRepository @@ -54,7 +55,10 @@ class PlatoonActivity : BaseActivity() { private lateinit var repository: PlatoonRepository private lateinit var screen: Screen private val work = BackgroundWork(this, "GF2Platoon") - private val memberAdapter = MemberAdapter() + // Scroll restores once rows exist, not onto the loading view of a recreated screen. + private val memberAdapter = MemberAdapter().apply { + stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY + } private val stateAdapter = SingleViewAdapter() private lateinit var summary: LinearLayout private lateinit var searchInput: EditText @@ -128,6 +132,21 @@ class PlatoonActivity : BaseActivity() { load() } + /** A roster captured while this screen is showing appears without leaving it. */ + private val rosterSaved: (String) -> Unit = { storageId -> + if (storageId == profileBinding.scope?.storageId) load() + } + + override fun onStart() { + super.onStart() + RosterEvents.addListener(rosterSaved) + } + + override fun onStop() { + RosterEvents.removeListener(rosterSaved) + super.onStop() + } + override fun onSaveInstanceState(outState: Bundle) { outState.putInt(STATE_FILTER, filter.ordinal) outState.putInt(STATE_SORT, sort.ordinal) @@ -155,13 +174,18 @@ class PlatoonActivity : BaseActivity() { ) { result -> result.fold( onSuccess = { loaded -> + // Rows show the latest roster and time zone, which their diff does not compare. + val rebind = model?.let { it.latest != loaded.latest || it.zone != loaded.zone } == true model = loaded selected.retainAll(loaded.statuses.mapTo(mutableSetOf(), MemberStatus::uid)) if (selecting) setSelecting(true) renderSummary(loaded) renderMembers() + if (rebind) memberAdapter.notifyItemRangeChanged(0, memberAdapter.itemCount) }, onFailure = { + // Search or filter changes must not bring back rows from before the failure. + model = null memberAdapter.submitList(emptyList()) stateAdapter.view = StateViews.failure(this, getString(R.string.platoon_load_failed)) { load() } }, diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index 8e990a7..faca57e 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -3,9 +3,11 @@ package dev.gf2log.app import android.os.Bundle import android.text.TextUtils import android.view.View +import dev.gf2log.app.management.MetricCertainty import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotComparison import dev.gf2log.app.management.SnapshotComparisonCsv +import dev.gf2log.app.management.WeeklyMetricPresentation import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.BarAction import dev.gf2log.app.ui.Feedback @@ -46,8 +48,11 @@ class SnapshotComparisonActivity : BaseActivity() { content.addView(StateViews.loading(this, getString(R.string.loading_comparison))) work.run( task = { - val snapshots = PlatoonRepository(this, scope).listSnapshots(2) - snapshots.takeIf { it.size == 2 }?.let { SnapshotComparison.compare(it[1], it[0]) } + val repository = PlatoonRepository(this, scope) + val snapshots = repository.listSnapshots(2) + snapshots.takeIf { it.size == 2 }?.let { + SnapshotComparison.compare(it[1], it[0], repository.gameZone()) + } }, ) { result -> content.removeAllViews() @@ -59,7 +64,7 @@ class SnapshotComparisonActivity : BaseActivity() { } override fun onScreenResumed() { - if (!profileBinding.isCurrent(this)) finish() + if (!profileBinding.isSameStorage(this)) finish() } private fun render(result: SnapshotComparison.Result?) { @@ -114,12 +119,20 @@ class SnapshotComparisonActivity : BaseActivity() { }, lp()) addView(row { listOf( - getString(R.string.merit_this_week_short) to change.weeklyMeritDelta, - getString(R.string.total_merit) to change.totalMeritDelta, - getString(R.string.gunsmoke_score) to change.totalScoreDelta, - ).forEachIndexed { badgeIndex, (label, delta) -> + Triple( + getString(R.string.merit_this_week_short), + change.weeklyMeritDelta, + change.weeklyMeritDeltaCertainty, + ), + Triple(getString(R.string.total_merit), change.totalMeritDelta, MetricCertainty.EXACT), + Triple( + getString(R.string.gunsmoke_score), + change.totalScoreDelta, + change.totalScoreDeltaCertainty, + ), + ).forEachIndexed { badgeIndex, (label, delta, certainty) -> addView(badge( - "$label +$delta", + "$label +${WeeklyMetricPresentation.format(delta, certainty)}", if (delta > 0) R.color.accent_surface else R.color.surface_variant, if (delta > 0) R.color.accent_text else R.color.text_secondary, ), lp(WRAP, WRAP, start = if (badgeIndex == 0) 0 else 6)) @@ -128,9 +141,9 @@ class SnapshotComparisonActivity : BaseActivity() { contentDescription = getString( R.string.member_change_description, change.name, - change.weeklyMeritDelta, + WeeklyMetricPresentation.format(change.weeklyMeritDelta, change.weeklyMeritDeltaCertainty), change.totalMeritDelta, - change.totalScoreDelta, + WeeklyMetricPresentation.format(change.totalScoreDelta, change.totalScoreDeltaCertainty), ) importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES }, lp()) diff --git a/app/src/main/java/dev/gf2log/app/StandbyActivity.kt b/app/src/main/java/dev/gf2log/app/StandbyActivity.kt index 7a18ac9..19fe8c2 100644 --- a/app/src/main/java/dev/gf2log/app/StandbyActivity.kt +++ b/app/src/main/java/dev/gf2log/app/StandbyActivity.kt @@ -38,6 +38,9 @@ class StandbyActivity : BaseActivity() { private var leaving = false private lateinit var root: FrameLayout + /** Every delayed guard this screen posts; removed with it, so none can act after it is gone. */ + private val guards = mutableListOf() + /** Set when an alarm opened this screen for a scheduled recording rather than the user. */ private val scheduledReset: Instant? by lazy { intent.getLongExtra(ScheduledRecording.EXTRA_RESET, -1).takeIf { it >= 0 }?.let(Instant::ofEpochMilli) @@ -114,7 +117,7 @@ class StandbyActivity : BaseActivity() { // Until a recording is armed nothing else bounds this screen, so it bounds itself: // never past the end of the server's grace period. val limit = reset.plus(StandbyPlanner.GRACE).toEpochMilli() + DEADLINE_MARGIN_MS - System.currentTimeMillis() - root.postDelayed({ if (!StandbyAutomation.isArmed) standDown() }, limit.coerceAtLeast(0)) + post(limit) { if (!StandbyAutomation.isArmed) standDown() } // A screen recreated after arming leaves the running recording alone. if (!StandbyAutomation.isArmed) { val keys = intent.getStringArrayExtra(ScheduledRecording.EXTRA_SERVERS)?.toSet().orEmpty() @@ -127,15 +130,30 @@ class StandbyActivity : BaseActivity() { private fun guardDeadline() { StandbyAutomation.deadline?.let { deadline -> val delay = deadline.toEpochMilli() + DEADLINE_MARGIN_MS - System.currentTimeMillis() - root.postDelayed({ finishForAutomation() }, delay.coerceAtLeast(0)) + post(delay) { finishForAutomation() } } } + private fun post(delayMs: Long, action: () -> Unit) { + val guard = Runnable(action) + guards += guard + root.postDelayed(guard, delayMs.coerceAtLeast(0)) + } + + override fun onDestroy() { + if (::root.isInitialized) guards.forEach(root::removeCallbacks) + guards.clear() + super.onDestroy() + } + /** A scheduled recording was armed after this screen opened. */ internal fun onScheduledArmed() = guardDeadline() /** Gives up a scheduled wake-up before anything ran: close and turn the screen back off. */ internal fun standDown() { + // A screen already closed (recording done, or the user tapped it) must never lock the + // phone afterwards. + if (leaving) return finishForAutomation() StandbyAutomationService.instance?.lockScreen() } diff --git a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt index c142a29..117a1b2 100644 --- a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt +++ b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt @@ -40,7 +40,8 @@ internal class StandbyFlow( private val vpnConsent = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == android.app.Activity.RESULT_OK) { - chooseServers(pendingServers, pendingPackages) + // Home recreated behind the system dialog lost the list: read it again. + if (pendingServers.isEmpty()) prepareRecording() else chooseServers(pendingServers, pendingPackages) } else { Feedback.show(activity, R.string.standby_vpn_needed, long = true) openStandby() @@ -59,7 +60,9 @@ internal class StandbyFlow( } .setPositiveButton(R.string.standby_battery_allow) { _, _ -> StandbyBatteryPrompt.markAsked(activity) - batteryExemption.launch(StandbyBatteryPrompt.requestIntent(activity)) + // Some phones have no such dialog; the exemption is a help, not a requirement. + runCatching { batteryExemption.launch(StandbyBatteryPrompt.requestIntent(activity)) } + .onFailure { prepareRecording() } }, ) } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt b/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt index 7a0468b..6ce701f 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt @@ -46,9 +46,8 @@ internal class WeeklyExportFlow( val csv = if (request == EXPORT_ALL) { WeeklyReportCsv.formatAll(repository().listAllWeeklyReports(zone)) } else { - WeeklyReportCsv.format( - repository().buildWeeklyTableRevision(LocalDate.ofEpochDay(request.toLong()), zone).report, - ) + // Exporting reads the displayed table; it never adds a history revision. + WeeklyReportCsv.format(repository().buildWeeklyReport(LocalDate.ofEpochDay(request.toLong()), zone)) } DocumentAccess.openOutput(activity.contentResolver, uri).writer(Charsets.UTF_8).use { it.write(csv) } }, @@ -93,7 +92,7 @@ internal class WeeklyExportFlow( fun exportAll() { work.run( - task = { repository().listAllWeeklyReports(GameTimeZonePreferences.get(activity, storageId())).isNotEmpty() }, + task = { repository().hasWeeklyReports(GameTimeZonePreferences.get(activity, storageId())) }, ) { result -> if (result.getOrDefault(false)) { pendingCsv = EXPORT_ALL diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 8a601bf..c5c1c2d 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -11,6 +11,7 @@ import androidx.activity.OnBackPressedCallback import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import dev.gf2log.app.capture.RosterEvents import dev.gf2log.app.management.MemberEvent import dev.gf2log.app.management.PlatoonPeriods import dev.gf2log.app.management.PlatoonRepository @@ -73,7 +74,10 @@ class WeeklyReportActivity : BaseActivity() { private val topAdapter = SingleViewAdapter() private val headerAdapter = SingleViewAdapter() - private val rowsAdapter = RowsAdapter() + // Scroll restores once rows exist, not onto the loading view of a recreated screen. + private val rowsAdapter = RowsAdapter().apply { + stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY + } private val stateAdapter = SingleViewAdapter() private val bottomAdapter = SingleViewAdapter() @@ -122,13 +126,22 @@ class WeeklyReportActivity : BaseActivity() { recreate() return } - reportState.onResume() requestRender(reconcileRetainedCsv = true) } - override fun onPause() { - if (::reportState.isInitialized) reportState.onPause() - super.onPause() + /** A roster captured while this screen is showing updates the table; edits in progress stay. */ + private val rosterSaved: (String) -> Unit = { storageId -> + if (::scope.isInitialized && storageId == scope.storageId) requestRender() + } + + override fun onStart() { + super.onStart() + RosterEvents.addListener(rosterSaved) + } + + override fun onStop() { + RosterEvents.removeListener(rosterSaved) + super.onStop() } override fun onSaveInstanceState(outState: Bundle) { @@ -157,13 +170,13 @@ class WeeklyReportActivity : BaseActivity() { // region Loading and rendering private fun requestRender(reconcileRetainedCsv: Boolean = false) { - val request = reportState.newRenderRequest() + val referenceDay = reportState.referenceDay if (model == null) stateAdapter.view = StateViews.loading(this, getString(R.string.weekly_report_loading)) work.run( key = RENDER, task = { if (reconcileRetainedCsv) repository.reconcileRetainedCsvFiles() - loadRenderModel(request.referenceDay) + loadRenderModel(referenceDay) }, ) { result -> result.fold( @@ -504,6 +517,8 @@ class WeeklyReportActivity : BaseActivity() { } private fun cancelEditing() { + // A save in flight already holds these edits; discarding now could not take them back. + if (work.isBusy(SAVE)) return if (edits.isEmpty()) { finishEditing() return @@ -526,18 +541,23 @@ class WeeklyReportActivity : BaseActivity() { val current = model ?: return if (work.isBusy(SAVE)) return val report = current.report + // The edits as tapped; whatever happens to the draft meanwhile, exactly these are saved. + val saving = edits.values.toList() // Merge against the stored overrides, not the displayed table: a restored revision or a member // missing from the roster would otherwise drop overrides the user never touched. work.run(key = SAVE, task = { repository.listWeeklyOverrides(report.periodStart.toEpochDay()) }) { stored -> - stored.onFailure { Feedback.show(this, R.string.save_failed) } - stored.onSuccess { saveMerged(current, it) } + stored.onFailure { + Feedback.show(this, R.string.save_failed) + updateTopBar() + } + stored.onSuccess { saveMerged(current, it, saving) } } updateTopBar() } - private fun saveMerged(current: RenderModel, existing: List) { + private fun saveMerged(current: RenderModel, existing: List, saving: List) { val report = current.report - when (val merged = WeeklyOverrideEditing.merge(report.periodStart, report.isGunsmokeWeek, existing, edits.values)) { + when (val merged = WeeklyOverrideEditing.merge(report.periodStart, report.isGunsmokeWeek, existing, saving)) { is WeeklyOverrideEditing.Result.Invalid -> { val name = current.namesByUid[merged.uid] ?: Labels.uid(this, merged.uid) Feedback.show( @@ -545,6 +565,7 @@ class WeeklyReportActivity : BaseActivity() { getString(R.string.invalid_weekly_edit_cell, name, Labels.dayHeader(this, merged.gameDay)), long = true, ) + updateTopBar() } is WeeklyOverrideEditing.Result.Valid -> { work.run( @@ -552,7 +573,7 @@ class WeeklyReportActivity : BaseActivity() { task = { repository.replaceWeeklyOverrides(report.periodStart.toEpochDay(), merged.overrides) }, ) { result -> if (result.isSuccess) { - edits.finish() + if (edits.isEditing(report.periodStart)) edits.finish() Feedback.show(this, R.string.weekly_edits_saved) requestRender() } else { diff --git a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt index 9fbedd9..5f95c3a 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt @@ -8,6 +8,7 @@ import android.widget.LinearLayout import androidx.activity.OnBackPressedCallback import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines +import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BarAction import dev.gf2log.app.ui.Dialogs @@ -31,6 +32,7 @@ class WeeklySettingsActivity : BaseActivity() { private lateinit var preferences: WeeklyCutlinePreferences private lateinit var initial: WeeklyCutlines private val inputs = mutableListOf() + private val work = BackgroundWork(this, "GF2WeeklySettings") private var restoredEnabled: BooleanArray? = null private val unsavedBack = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() = leave() @@ -74,7 +76,7 @@ class WeeklySettingsActivity : BaseActivity() { } override fun onScreenResumed() { - if (!profileBinding.isCurrent(this)) finish() + if (!profileBinding.isSameStorage(this)) finish() } private fun LinearLayout.threshold(id: Int, label: Int, value: Long?, maximum: Int?) { @@ -130,10 +132,18 @@ class WeeklySettingsActivity : BaseActivity() { ) private fun save() { + if (work.isBusy(SAVE)) return val values = readValues() ?: return - preferences.write(current(values)) - Feedback.show(this, R.string.saved) - finish() + val cutlines = current(values) + // A failed write keeps the screen and its edits instead of crashing. + work.run(key = SAVE, task = { preferences.write(cutlines) }) { result -> + if (result.isSuccess) { + Feedback.show(this, R.string.saved) + finish() + } else { + Feedback.show(this, R.string.save_failed) + } + } } private fun leave() { @@ -159,6 +169,7 @@ class WeeklySettingsActivity : BaseActivity() { ) private companion object { + const val SAVE = "save" const val DAILY_ATTEMPTS = 3 const val WEEKLY_ATTEMPTS = 21 const val WEEK_DAYS = 7 diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt index 1bf5477..be68c85 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt @@ -51,7 +51,7 @@ class WeeklyTableHistoryActivity : BaseActivity() { } override fun onScreenResumed() { - if (!profileBinding.isCurrent(this)) { + if (!profileBinding.isSameStorage(this)) { finish() return } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt index 767a9ad..ac8f533 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt @@ -97,7 +97,7 @@ class WeeklyTableHistoryPreviewActivity : BaseActivity() { } override fun onScreenResumed() { - if (!profileBinding.isCurrent(this)) finish() + if (!profileBinding.isSameStorage(this)) finish() } private fun confirmRestore() { diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt index 25f5227..b595982 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt @@ -7,7 +7,11 @@ import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference -enum class CapturePhase { STOPPED, STARTING, RUNNING } +/** + * [STOPPING] lasts from a stop request until the native loop is joined, queued chunks are drained, + * and the session is cleared; only then is capture [STOPPED] and data operations may begin. + */ +enum class CapturePhase { STOPPED, STARTING, RUNNING, STOPPING } /** Every user-visible capture status message, localized through [messageRes]. */ enum class CaptureNotice(@StringRes val messageRes: Int, val severity: Severity) { @@ -84,6 +88,8 @@ data class CaptureSnapshot( val notice: CaptureNoticeEvent?, val capturedTargets: List, val guided: CaptureStatus.GuidedProgress?, + /** Increases each time capture reaches [CapturePhase.RUNNING], so a new run is distinguishable. */ + val runCount: Long = 0, ) /** Process-wide capture state, swapped atomically as one immutable [CaptureSnapshot]. */ @@ -113,9 +119,13 @@ object CaptureStatus { it.copy(phase = CapturePhase.STARTING, notice = event(CaptureNotice.PREPARING)) } - fun markStopped() = markStopped(notice = null) + /** A start abandoned before it reached the service only clears a stale notice. */ + fun clearIdleNotice() = update { + if (it.phase != CapturePhase.STOPPED) it else it.copy(notice = null, capturedTargets = emptyList()) + } - internal fun markStopped(notice: CaptureNotice?, vararg args: String) = update { + /** Publishes STOPPED directly; only the service calls this, and only with no teardown pending. */ + internal fun markStopped(notice: CaptureNotice? = null, vararg args: String) = update { it.copy( phase = CapturePhase.STOPPED, notice = notice?.let { value -> event(value, args) }, @@ -123,14 +133,51 @@ object CaptureStatus { ) } + internal fun markStopping(notice: CaptureNotice?, vararg args: String) = update { + it.copy( + phase = CapturePhase.STOPPING, + notice = notice?.let { value -> event(value, args) }, + capturedTargets = emptyList(), + ) + } + + /** + * Publishes STOPPED once teardown has finished, keeping the stop notice. A phase that a newer + * start already owns is left alone. + */ + internal fun markTornDown() = update { + if (it.phase != CapturePhase.STOPPING) it else it.copy(phase = CapturePhase.STOPPED) + } + internal fun markRunning(targets: List) = update { it.copy( phase = CapturePhase.RUNNING, notice = event(CaptureNotice.CAPTURING, arrayOf(targets.joinToString())), capturedTargets = targets.toList(), + runCount = it.runCount + 1, ) } + /** + * Runs [action] once on the main looper as soon as capture is [CapturePhase.STOPPED], which may + * be immediately. Returns a function that cancels the wait; cancelling after it ran is harmless. + */ + fun whenStopped(action: () -> Unit): () -> Unit { + val done = AtomicBoolean(false) + lateinit var listener: (CaptureSnapshot) -> Unit + listener = { snapshot -> + if (snapshot.phase == CapturePhase.STOPPED && done.compareAndSet(false, true)) { + removeListener(listener) + action() + } + } + addListener(listener) + return { + done.set(true) + removeListener(listener) + } + } + internal fun report(notice: CaptureNotice, vararg args: String) = update { it.copy(notice = event(notice, args)) } diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt index 5000ebf..629fb7a 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt @@ -13,13 +13,21 @@ internal class CaptureTrafficStats { private val droppedChunks = AtomicLong() private val reportedBucket = AtomicLong() private val unknownPayloads = ConcurrentHashMap() + private val otherUnknownPayloads = AtomicLong() @Volatile private var startedAt: Instant? = null fun begin(now: Instant = Instant.now()) { - listOf(forwardedBytes, inspectedBytes, decodedPayloads, warnings, droppedChunks, reportedBucket) - .forEach { it.set(0) } + listOf( + forwardedBytes, + inspectedBytes, + decodedPayloads, + warnings, + droppedChunks, + reportedBucket, + otherUnknownPayloads, + ).forEach { it.set(0) } unknownPayloads.clear() startedAt = now } @@ -29,8 +37,18 @@ internal class CaptureTrafficStats { if (warnings > 0) this.warnings.addAndGet(warnings.toLong()) } + /** + * Counts the first [MAX_UNKNOWN_TYPES] distinct types individually and every later one together, + * so a garbage plaintext flow cannot grow the map, or the persisted diagnostics, to 65,536 types. + */ fun recordUnknown(payloadType: Int) { - unknownPayloads.computeIfAbsent(payloadType) { AtomicLong() }.incrementAndGet() + val counter = unknownPayloads[payloadType] + ?: if (unknownPayloads.size < MAX_UNKNOWN_TYPES) { + unknownPayloads.computeIfAbsent(payloadType) { AtomicLong() } + } else { + otherUnknownPayloads + } + counter.incrementAndGet() } fun recordDropped(): Long = droppedChunks.incrementAndGet() @@ -63,14 +81,20 @@ internal class CaptureTrafficStats { decodedPayloads = decodedPayloads.get(), warnings = warnings.get(), droppedChunks = droppedChunks.get(), - unknownPayloads = unknownPayloads.entries - .sortedBy { it.key } - .joinToString { "${it.key}:${it.value.get()}" }, + unknownPayloads = ( + unknownPayloads.entries + .sortedBy { it.key } + .map { "${it.key}:${it.value.get()}" } + + listOfNotNull(otherUnknownPayloads.get().takeIf { it > 0 }?.let { "*:$it" }) + ).joinToString(), finalStatus = finalStatus, ) } - private companion object { - const val TRAFFIC_REPORT_BYTES = 64 * 1024 + companion object { + private const val TRAFFIC_REPORT_BYTES = 64 * 1024 + + /** Types beyond this many are reported together as `*`. */ + const val MAX_UNKNOWN_TYPES = 32 } } 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 d83f776..4e272a9 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -1,14 +1,18 @@ package dev.gf2log.app.capture import android.app.Service +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.VpnService +import android.os.Build import android.os.Handler import android.os.Looper import android.os.ParcelFileDescriptor +import dev.gf2log.app.LanguagePreferences import dev.gf2log.app.R import dev.gf2log.app.SupportedGamePackages +import dev.gf2log.app.management.CsvImportCheckpointManager import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope @@ -22,8 +26,14 @@ import dev.gf2log.app.settings.PayloadHistoryPreferences * Every field is confined to the main thread. Blocking work (history migration, joining the native * thread, draining queued chunks) runs on [CaptureWorker], whose FIFO order guarantees teardown as * "stop native, then drain parser, then clear state". A start requested during teardown waits for it. + * [CapturePhase.STOPPING] covers that teardown; STOPPED is published only once it has finished. */ class CaptureVpnService : VpnService() { + // Notifications and stored notices follow the app's language, not the device's. + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(LanguagePreferences.wrap(newBase)) + } + private enum class State { IDLE, STARTING, RUNNING, STOPPING } private val mainHandler = Handler(Looper.getMainLooper()) @@ -175,9 +185,13 @@ class CaptureVpnService : VpnService() { } worker.execute { val migration = runCatching { - profileRegistry.ensureInitialized().forEach { profile -> - PlatoonRepository(this, PlatoonStorageScope(profile.storageId)) - .reconcileRetainedCsvFiles() + val scopes = profileRegistry.ensureInitialized().map { PlatoonStorageScope(it.storageId) } + // An interrupted CSV import or undo must finish first, or its later recovery would + // overwrite what this capture ingests. A failing scope stays for the next attempt. + CsvImportCheckpointManager.recoverInterruptedImports(this, scopes) + scopes.forEach { scope -> + GuildMembersCsvWriter.deleteStalePartials(scope.retainedCsvDirectory(this)) + PlatoonRepository(this, scope).reconcileRetainedCsvFiles() } } mainHandler.post { @@ -199,6 +213,10 @@ class CaptureVpnService : VpnService() { .addRoute("0.0.0.0", 0) .addAddress(VPN_IPV6_ADDRESS, VPN_IPV6_PREFIX_LENGTH) .addRoute("::", 0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // From API 29 a VPN is metered unless declared otherwise; follow the underlying network. + builder.setMetered(false) + } val installedTargets = SupportedGamePackages.all.filter { targetPackage -> try { @@ -249,18 +267,28 @@ class CaptureVpnService : VpnService() { mainHandler.removeCallbacks(captureOnceGraceStop) restartCaptureOnce = null val lastNotice = CaptureStatus.snapshot().notice - CaptureStatus.markStopped(notice, *args) notifications.remove() when (state) { State.IDLE -> { + // A teardown inherited from a destroyed instance still owns the phase until it ends. + if (teardownsInFlight == 0) { + CaptureStatus.markStopped(notice, *args) + } else { + CaptureStatus.markStopping(notice, *args) + } if (!destroyed) stopSelf() return } - State.STOPPING -> return + State.STOPPING -> { + CaptureStatus.markStopping(notice, *args) + return + } State.STARTING, State.RUNNING -> Unit } + CaptureStatus.markStopping(notice, *args) state = State.STOPPING + teardownsInFlight++ run = null val descriptor = tunnel tunnel = null @@ -286,12 +314,18 @@ class CaptureVpnService : VpnService() { private fun onTeardownComplete() { state = State.IDLE + teardownsInFlight-- + val restart = restartCaptureOnce + restartCaptureOnce = null + when { + restart == null -> CaptureStatus.markTornDown() + // A destroyed service drops the restart it accepted, so its STARTING must not linger. + destroyed -> CaptureStatus.markStopped() + } if (destroyed) { worker.shutdown() return } - val restart = restartCaptureOnce - restartCaptureOnce = null if (restart != null) start(restart) else stopSelf() } @@ -310,5 +344,8 @@ class CaptureVpnService : VpnService() { private const val VPN_MTU = 1500 private const val PARSER_QUEUE_CAPACITY = 256 private const val CAPTURE_ONCE_GRACE_MILLIS = 60_000L + + /** Process-wide and main-thread only: a teardown can outlive the instance that began it. */ + private var teardownsInFlight = 0 } } diff --git a/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt b/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt index 891434a..46cf00a 100644 --- a/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt +++ b/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt @@ -17,7 +17,8 @@ import java.time.format.DateTimeFormatter * already merges continuation frames, so a roster payload that does not complete its logical * payload was cut short by another payload or by flow closure. Like the reference logger, such a * boundary ends the dataset; it is discarded instead of being merged with a later, unrelated roster. - * An empty `.partial` file reserves the final name while the CSV is written. + * An empty `.partial` file reserves the final name while the CSV is written; it is deleted when + * writing or publication fails, and [deleteStalePartials] removes any a process death left behind. */ class GuildMembersCsvWriter( private val outputDirectory: File, @@ -28,14 +29,16 @@ class GuildMembersCsvWriter( @Synchronized fun accept(payload: ParsedPayload): SaveResult? { val data = payload.data as? GuildMembersData ?: return null - check(GuildMembersCsv.hasValidMemberBounds(data.members)) { - "Platoon roster exceeds the member or name limit" - } if (payload.messageId == 0 || !payload.isEndOfMessage) return null val members = linkedMapOf() data.members.forEach { member -> members[member.uid] = member } if (members.isEmpty()) return null - return publish(members.values.toList()) + // Bounded after deduplication: merged continuation frames may repeat a member. + val roster = members.values.toList() + check(GuildMembersCsv.hasValidMemberBounds(roster)) { + "Platoon roster exceeds the member or name limit" + } + return publish(roster) } private fun publish(members: List): SaveResult { @@ -49,6 +52,8 @@ class GuildMembersCsvWriter( } publishWithRetry(batch.workingFile, batch.completed.file) } catch (error: Exception) { + // Nothing reads a working file, so an unpublished one would only accumulate. + batch.workingFile.delete() // Structured ingestion still gets the completed batch; the caller sees the failure. runCatching { onBatchClosed(batch.completed) } .exceptionOrNull() @@ -68,7 +73,7 @@ class GuildMembersCsvWriter( while (true) { val name = if (suffix == 1) "$filenameStem.csv" else "${filenameStem}_$suffix.csv" val finalFile = File(outputDirectory, name) - val workingFile = File(outputDirectory, "$name.partial") + val workingFile = File(outputDirectory, name + WORKING_SUFFIX) if (!finalFile.exists() && workingFile.createNewFile()) { return Reservation(workingFile, CompletedBatch(finalFile, LogTime.format(instant), members)) } @@ -107,6 +112,16 @@ class GuildMembersCsvWriter( companion object { private const val PUBLISH_ATTEMPTS = 2 + private const val WORKING_SUFFIX = ".partial" + + /** + * Deletes working files a process death left in [directory]. Call it only while no writer + * for that directory exists, such as before a capture binds its first flow. + */ + fun deleteStalePartials(directory: File) { + directory.listFiles { file -> file.isFile && file.name.endsWith(WORKING_SUFFIX) } + ?.forEach(File::delete) + } private val FILE_TIME_FORMAT: DateTimeFormatter = DateTimeFormatter .ofPattern("yyyyMMdd'T'HHmmss'Z'") diff --git a/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt b/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt index 68119c6..a36d04f 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt @@ -21,6 +21,12 @@ internal object PendingPlatoonAdmissionStore { val profile: PlatoonProfileData, val firstObservedAt: Instant, val payloadCount: Int, + /** True while a confirmation holds the claim; the prompt stays open to report its outcome. */ + val admitting: Boolean = false, + /** Why the latest confirmation failed; cleared when the next one claims the candidate. */ + val failure: CaptureNotice? = null, + /** Confirmations that have claimed this candidate so far, including the running one. */ + val attempts: Int = 0, ) data class BufferedPayload( @@ -56,6 +62,8 @@ internal object PendingPlatoonAdmissionStore { val flowIds: MutableSet = linkedSetOf(), val endedFlowIds: MutableSet = linkedSetOf(), var claimed: Boolean = false, + var failure: CaptureNotice? = null, + var attempts: Int = 0, ) private data class IdentityKey(val ownerPackage: String, val platoonId: UInt) @@ -66,8 +74,10 @@ internal object PendingPlatoonAdmissionStore { private val listeners = CopyOnWriteArraySet<(List) -> Unit>() /** - * [listener] receives the visible candidates on the main looper: once immediately, then - * whenever a candidate appears, changes identity, is claimed or released, or is removed. + * [listener] receives every candidate on the main looper: once immediately, then whenever a + * candidate appears, changes identity, is claimed or released, or is removed. A claimed + * candidate stays listed as [Summary.admitting], so a slow confirmation does not close its + * prompt, and a failed one is released with its [Summary.failure] for that prompt to show. */ fun addListener(listener: (List) -> Unit) { listeners += listener @@ -143,7 +153,6 @@ internal object PendingPlatoonAdmissionStore { @Synchronized fun summaries(): List = candidates.values .asSequence() - .filterNot(Candidate::claimed) .sortedBy(Candidate::firstObservedAt) .map { it.summary() } .toList() @@ -158,6 +167,8 @@ internal object PendingPlatoonAdmissionStore { val candidate = candidates[token] ?: return null if (candidate.claimed) return null candidate.claimed = true + candidate.failure = null + candidate.attempts += 1 notifyChanged() return Claim( summary = candidate.summary(), @@ -168,10 +179,11 @@ internal object PendingPlatoonAdmissionStore { } @Synchronized - fun releaseClaim(token: String) { + fun releaseClaim(token: String, failure: CaptureNotice? = null) { val candidate = candidates[token] ?: return if (!candidate.claimed) return candidate.claimed = false + candidate.failure = failure notifyChanged() } @@ -179,28 +191,26 @@ internal object PendingPlatoonAdmissionStore { fun complete(token: String): Set = removeLocked(token)?.flowIds.orEmpty() /** - * Claims [token] and runs [admit] outside the lock. The candidate is completed when [admit] - * returns true and released for another decision otherwise, including when it throws, so a - * failed confirmation can never leave a candidate claimed and invisible forever. + * Claims [token] and runs [admit] outside the lock. [admit] returns null once the candidate is + * admitted, which completes it, or the failure to show, which releases it for another + * decision. A throwing [admit] releases it with [CaptureNotice.ADMISSION_INVALID], so a failed + * confirmation can never leave a candidate claimed forever or its prompt without an outcome. * Returns the completed candidate's flow IDs, or null when nothing was completed. */ - fun withClaim(token: String, admit: (Claim) -> Boolean): Set? { + fun withClaim(token: String, admit: (Claim) -> CaptureNotice?): Set? { val claim = claim(token) ?: return null - var admitted = false + var failure: CaptureNotice? = CaptureNotice.ADMISSION_INVALID try { - admitted = admit(claim) + failure = admit(claim) } finally { - if (!admitted) releaseClaim(token) + failure?.let { releaseClaim(token, it) } } - return if (admitted) complete(token) else null + return if (failure == null) complete(token) else null } @Synchronized fun discard(token: String): Set = removeLocked(token)?.flowIds.orEmpty() - @Synchronized - fun contains(token: String): Boolean = token in candidates - @VisibleForTesting @Synchronized internal fun clearForTests() { @@ -216,6 +226,9 @@ internal object PendingPlatoonAdmissionStore { profile = profile, firstObservedAt = firstObservedAt, payloadCount = payloads.size, + admitting = claimed, + failure = failure, + attempts = attempts, ) private fun removeLocked(token: String): Candidate? { diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt index 2f69e16..73066ff 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt @@ -84,8 +84,13 @@ internal class PlatoonAdmissionCoordinator( fun confirm(token: String, region: GameServerRegion) { var admitted: PlatoonProfile? = null val completedFlows = PendingPlatoonAdmissionStore.withClaim(token) { claim -> - admitted = admit(claim, region) - admitted != null + when (val outcome = admit(claim, region)) { + is Outcome.Admitted -> { + admitted = outcome.profile + null + } + is Outcome.Failed -> outcome.notice + } } ?: return completedFlows.forEach { tokenByFlow.remove(it, token) } val profile = admitted ?: return @@ -97,12 +102,11 @@ internal class PlatoonAdmissionCoordinator( ) } - /** Persists the claimed identity and replays its payloads; null after reporting a failure. */ - private fun admit(claim: PendingPlatoonAdmissionStore.Claim, region: GameServerRegion): PlatoonProfile? { + /** Persists the claimed identity and replays its payloads, reporting any failure. */ + private fun admit(claim: PendingPlatoonAdmissionStore.Claim, region: GameServerRegion): Outcome { val ownerPackage = claim.summary.ownerPackage if (region !in clientServerRegions.allowed(ownerPackage)) { - CaptureStatus.report(CaptureNotice.ADMISSION_REGION_MISMATCH) - return null + return failed(CaptureNotice.ADMISSION_REGION_MISMATCH) } val client = requireNotNull(PlatoonClient.fromPackage(ownerPackage)) val existing = profileRegistry.findByIdentity( @@ -111,8 +115,7 @@ internal class PlatoonAdmissionCoordinator( claim.summary.profile.platoonId.toLong(), ) if (!admissionGate.canAdmit(ownerPackage, existing != null)) { - CaptureStatus.report(CaptureNotice.ADMISSION_LIMIT) - return null + return failed(CaptureNotice.ADMISSION_LIMIT) } val previousRegion = clientServerRegions.stored(ownerPackage) val profile = runCatching { @@ -130,37 +133,43 @@ internal class PlatoonAdmissionCoordinator( clientServerRegions.set(ownerPackage, previousRegion) } }.onFailure(error::addSuppressed) - CaptureStatus.report(CaptureNotice.ADMISSION_PROFILE_FAILED) - return null + return failed(CaptureNotice.ADMISSION_PROFILE_FAILED) } if (existing == null) admissionGate.markAdmitted(ownerPackage) flows.profileChanged(profile.storageId) - if (!replay(claim, profile)) { - CaptureStatus.report(CaptureNotice.ADMISSION_REPLAY_FAILED) - return null - } - return profile + if (!replay(claim, profile)) return failed(CaptureNotice.ADMISSION_REPLAY_FAILED) + return Outcome.Admitted(profile) } - /** Binds live flows to [profile] and routes the buffered payloads in their captured order. */ + private fun failed(notice: CaptureNotice): Outcome { + CaptureStatus.report(notice) + return Outcome.Failed(notice) + } + + /** + * Binds every claimed flow to [profile] before any buffered payload is routed, then routes + * them in their captured order. Binding is the only step that fails the replay, and it fails + * before anything was persisted, so a retry cannot duplicate history or roster files. Once + * routing starts the replay completes: each payload reports its own save failures. + */ private fun replay(claim: PendingPlatoonAdmissionStore.Claim, profile: PlatoonProfile): Boolean { - val sessions = mutableMapOf() - val replayed = runCatching { - // Live flows stay bound; an ended flow needs a session only while its payloads replay. - claim.flowIds.filterNot(claim.endedFlowIds::contains).forEach { flowId -> - sessions[flowId] = flows.bind(flowId, profile) - } - claim.payloads.forEach { buffered -> - flows.route( - sessions.getOrPut(buffered.flowId) { flows.bind(buffered.flowId, profile) }, - buffered.payload, - ) - } + val sessions = linkedMapOf() + // Live flows stay bound; an ended flow needs a session only while its payloads replay. + val flowIds = claim.flowIds.filterNot(claim.endedFlowIds::contains) + + claim.payloads.map(PendingPlatoonAdmissionStore.BufferedPayload::flowId) + val bound = runCatching { + flowIds.forEach { flowId -> sessions.getOrPut(flowId) { flows.bind(flowId, profile) } } } - if (replayed.isFailure) { + if (bound.isFailure) { sessions.forEach { (flowId, session) -> flows.unbind(flowId, session) } return false } + claim.payloads.forEach { buffered -> + runCatching { flows.route(sessions.getValue(buffered.flowId), buffered.payload) } + .onFailure { error -> + CaptureStatus.report(CaptureNotice.PROCESSING_FAILED, error.javaClass.simpleName) + } + } claim.endedFlowIds.forEach { flowId -> sessions.remove(flowId)?.let { flows.unbind(flowId, it) } } @@ -198,6 +207,11 @@ internal class PlatoonAdmissionCoordinator( } } + private sealed interface Outcome { + data class Admitted(val profile: PlatoonProfile) : Outcome + data class Failed(val notice: CaptureNotice) : Outcome + } + companion object { const val MAX_STATUS_NAME_CHARS = 40 diff --git a/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookSecretStore.kt b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookSecretStore.kt index 04d2e19..3f07514 100644 --- a/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookSecretStore.kt +++ b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookSecretStore.kt @@ -43,12 +43,20 @@ class DiscordWebhookSecretStore(context: Context) { val decoded = cipher.doFinal(Base64.decode(ciphertext, Base64.NO_WRAP)) .toString(Charsets.UTF_8) DiscordWebhookPolicy.requireValid(decoded).toASCIIString() - }.getOrElse { - clear() + }.getOrElse { error -> + // Only data that can never decrypt again is dropped; a passing Keystore hiccup keeps + // the saved webhook for the next attempt. + if (isPermanent(error)) runCatching { clear() } null } } + private fun isPermanent(error: Throwable): Boolean = + error is javax.crypto.AEADBadTagException || + error is android.security.keystore.KeyPermanentlyInvalidatedException || + // Missing key, malformed stored values, or an address the policy no longer accepts. + error is IllegalArgumentException + fun clear() { check(preferences.edit().clear().commit()) { "Unable to clear the encrypted Discord webhook" diff --git a/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt b/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt index d3dfed0..b2ad4c7 100644 --- a/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt +++ b/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt @@ -285,8 +285,9 @@ internal object ActivityIngestion { /** * Upgrades an inferred roster boundary to an exact one when a single linked activity fact - * falls within the correlation window. Candidate boundaries are loaded once per ingest; an - * applied boundary becomes exact and therefore leaves the candidate set. + * falls within its [MembershipCorrelationWindow]: after the previous roster and no later + * than the roster that revealed the change. Candidate boundaries are loaded once per ingest; + * an applied boundary becomes exact and therefore leaves the candidate set. */ private fun reconcileInferredMembershipBoundaries(db: SQLiteDatabase) { val boundariesByUid = readInferredBoundaries(db) @@ -327,9 +328,11 @@ internal object ActivityIngestion { activities.forEach { activity -> val candidates = boundariesByUid[activity.uid] ?: return@forEach val boundary = candidates.singleOrNull { candidate -> - kotlin.math.abs( - candidate.inferredAt.toEpochMilli() - activity.occurredAt.toEpochMilli(), - ) <= MEMBERSHIP_CORRELATION_WINDOW_MILLIS + MembershipCorrelationWindow.admits( + occurredAt = activity.occurredAt, + inferredAt = candidate.inferredAt, + previousRosterAt = candidate.previousRosterAt, + ) } ?: return@forEach val eventType = when (boundary.boundary) { MembershipBoundary.WITHDRAW -> MemberEventType.LEFT @@ -354,10 +357,13 @@ internal object ActivityIngestion { private fun readInferredBoundaries(db: SQLiteDatabase): Map> = db.rawQuery( """ - SELECT id, uid, joined_at, left_at, joined_precision, left_precision - FROM membership_periods - WHERE (joined_at IS NOT NULL AND joined_precision = ?) - OR (left_at IS NOT NULL AND left_precision = ?) + SELECT period.id, period.uid, period.joined_at, period.left_at, + period.joined_precision, period.left_precision, + (SELECT MAX(captured_at) FROM snapshots WHERE captured_at < period.joined_at), + (SELECT MAX(captured_at) FROM snapshots WHERE captured_at < period.left_at) + FROM membership_periods period + WHERE (period.joined_at IS NOT NULL AND period.joined_precision = ?) + OR (period.left_at IS NOT NULL AND period.left_precision = ?) """.trimIndent(), arrayOf(EvidencePrecision.INFERRED.name, EvidencePrecision.INFERRED.name), ).use { cursor -> @@ -368,11 +374,23 @@ internal object ActivityIngestion { val forMember = boundaries.getOrPut(uid, ::mutableListOf) val joinedAt = cursor.getNullableInstant(2) if (joinedAt != null && cursor.getString(4) == EvidencePrecision.INFERRED.name) { - forMember += InferredBoundary(membershipPeriodId, uid, MembershipBoundary.JOIN, joinedAt) + forMember += InferredBoundary( + membershipPeriodId, + uid, + MembershipBoundary.JOIN, + joinedAt, + cursor.getNullableInstant(6), + ) } val leftAt = cursor.getNullableInstant(3) if (leftAt != null && cursor.getNullableString(5) == EvidencePrecision.INFERRED.name) { - forMember += InferredBoundary(membershipPeriodId, uid, MembershipBoundary.WITHDRAW, leftAt) + forMember += InferredBoundary( + membershipPeriodId, + uid, + MembershipBoundary.WITHDRAW, + leftAt, + cursor.getNullableInstant(7), + ) } } boundaries @@ -402,12 +420,14 @@ internal object ActivityIngestion { val membershipPeriodId: Long, val uid: Long, val boundary: MembershipBoundary, + /** Capture time of the roster that revealed the change. */ val inferredAt: Instant, + /** Capture time of the roster before it, which still showed the earlier state. */ + val previousRosterAt: Instant?, ) private const val MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS = 250 private const val ACTIVITY_RESOLUTION_CURSOR_KEY = "activity_resolution_cursor" private const val DAILY_PATROL_RELATED_ACTION_ID = 801005L private const val NAME_RESOLUTION_WINDOW_MILLIS = 30L * 24L * 60L * 60L * 1000L - private const val MEMBERSHIP_CORRELATION_WINDOW_MILLIS = 12L * 60L * 60L * 1000L } diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt index c621008..7ecc391 100644 --- a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.management import android.content.Context +import android.util.Log import dev.gf2log.app.settings.ScopedAppSettingsStore import java.io.File import java.io.FileOutputStream @@ -157,10 +158,12 @@ class CsvImportCheckpointManager internal constructor( } val expectedDigest = digest(root).readText(Charsets.US_ASCII) require(expectedDigest.matches(SHA256)) { "Invalid CSV checkpoint digest" } + // Fails fast before quarantining anything; the authoritative comparison runs again + // inside the exclusive section that installs the checkpoint. check(backupManager().currentDatabaseSha256() == expectedDigest) { "Platoon data changed after the CSV import; undo would overwrite newer changes" } - restoreUnchecked() + restoreUnchecked(expectedDigest) } finally { releaseOperation() } @@ -179,7 +182,11 @@ class CsvImportCheckpointManager internal constructor( restoreUnchecked() } - private fun restoreUnchecked() { + /** + * With [expectedDatabaseSha256], the checkpoint is installed only while the live database still + * has that digest, compared under the same exclusive lock, so a concurrent ingest is never lost. + */ + private fun restoreUnchecked(expectedDatabaseSha256: String? = null) { val names = manifest(root).readLines(Charsets.UTF_8) .filter(String::isNotBlank) .toSet() @@ -208,7 +215,9 @@ class CsvImportCheckpointManager internal constructor( } restoreObserver(checkpoint) } - archive(root).inputStream().use(backupManager::restoreCheckpoint) + archive(root).inputStream().use { input -> + backupManager.restoreCheckpoint(input, expectedDatabaseSha256) + } } catch (error: Exception) { val marker = restoreMarker(root) if (marker.exists() && !marker.delete()) { @@ -349,20 +358,48 @@ class CsvImportCheckpointManager internal constructor( private fun digest(directory: File) = File(directory, DIGEST_FILE) private fun restoreMarker(directory: File) = File(directory, RESTORE_MARKER) - private companion object { - val SCOPE_STATES = mutableMapOf() + companion object { + private const val TAG = "GF2CsvCheckpoint" + private val SCOPE_STATES = mutableMapOf() + + private const val CHECKPOINT_DIRECTORY = "csv-import-checkpoint" + private const val STAGING_DIRECTORY = "csv-import-checkpoint.staging" + private const val PREVIOUS_DIRECTORY = "csv-import-checkpoint.previous" + private const val DISCARDED_DIRECTORY = "csv-import-checkpoint.discarded" + private const val QUARANTINE_DIRECTORY = "retained-quarantine" + private const val ARCHIVE_FILE = "platoon.gf2backup" + private const val MANIFEST_FILE = "planned-files.txt" + private const val DIGEST_FILE = "post-import.sha256" + private const val RESTORE_MARKER = "restore-database-installed" + private const val MAX_PLANNED_FILES = 64 + private val SAFE_IMPORT_NAME = Regex("import-\\d{8}T\\d{6}Z-[0-9a-f]{20}\\.csv") + private val SHA256 = Regex("[0-9a-f]{64}") - const val CHECKPOINT_DIRECTORY = "csv-import-checkpoint" - const val STAGING_DIRECTORY = "csv-import-checkpoint.staging" - const val PREVIOUS_DIRECTORY = "csv-import-checkpoint.previous" - const val DISCARDED_DIRECTORY = "csv-import-checkpoint.discarded" - const val QUARANTINE_DIRECTORY = "retained-quarantine" - const val ARCHIVE_FILE = "platoon.gf2backup" - const val MANIFEST_FILE = "planned-files.txt" - const val DIGEST_FILE = "post-import.sha256" - const val RESTORE_MARKER = "restore-database-installed" - const val MAX_PLANNED_FILES = 64 - val SAFE_IMPORT_NAME = Regex("import-\\d{8}T\\d{6}Z-[0-9a-f]{20}\\.csv") - val SHA256 = Regex("[0-9a-f]{64}") + /** + * Finishes every [scopes] interrupted import or undo, as constructing a manager does. Runs at + * process start and before capture ingests, so a recovery can never overwrite newer + * evidence. A failing scope is logged and left for the next attempt; returns how many failed. + */ + internal fun recoverInterruptedImports(context: Context, scopes: List): Int = + scopes.count { scope -> + try { + CsvImportCheckpointManager(context, scope) + false + } catch (error: Exception) { + Log.e(TAG, "CSV import recovery failed for ${scope.storageId}", error) + true + } + } + + /** [recoverInterruptedImports] for every registered Platoon. */ + internal fun recoverInterruptedImports(context: Context): Int { + val scopes = try { + PlatoonProfileRegistry(context).ensureInitialized().map { PlatoonStorageScope(it.storageId) } + } catch (error: Exception) { + Log.e(TAG, "Platoon profiles unavailable for CSV import recovery", error) + return 1 + } + return recoverInterruptedImports(context, scopes) + } } } diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportPreviewAnalyzer.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportPreviewAnalyzer.kt index cf30c33..304458c 100644 --- a/app/src/main/java/dev/gf2log/app/management/CsvImportPreviewAnalyzer.kt +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportPreviewAnalyzer.kt @@ -48,9 +48,12 @@ object CsvImportPreviewAnalyzer { val existingNames = existingMembers.associate { it.uid to it.name } fun isHistorical(item: PlatoonCsvImportStore.PreparedImport): Boolean { val baseline = latestSnapshot ?: return false - return item.capturedAt.isBefore(baseline.capturedAt) || - (item.capturedAt == baseline.capturedAt && - item.fileName <= baseline.sourceFile.orEmpty()) + return SnapshotReconciler.isHistorical( + item.capturedAt, + item.fileName, + baseline.capturedAt, + baseline.sourceFile, + ) } val historicalFiles = actionable.count(::isHistorical) diff --git a/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt b/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt index f9771dd..e4541a7 100644 --- a/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt @@ -15,6 +15,9 @@ import java.time.ZoneId * prefix, while the Monday-reset weekly counter constrains merit. Contiguous * observed runs are solved independently so missing days never discard later * usable evidence. Only values shared by every compatible path are published. + * When the detailed point search would exceed its budget, the rest of the run + * continues on attempt and Login / Daily Patrol classes with merit and score + * ranges, which admit every point path and therefore stay conservative. */ internal object GunsmokeWeekSolver { /** @@ -87,14 +90,19 @@ internal object GunsmokeWeekSolver { aggregate.dayConsensus.forEachIndexed { dayOffset, consensus -> val cellIndex = dayOffset + 1 val cell = resolved[cellIndex] + // A final event packet before the 05:00 reset that ends Saturday can miss a + // later Saturday login or patrol, so only a positive consensus holds there. + val finalized = cellIndex < resolved.lastIndex || cell.hasClosingBoundary if (cell.manualOverride == null) { resolved[cellIndex] = cell.copy( solvedAttempts = consensus.attempts ?: cell.solvedAttempts, solvedAttemptsCertainty = consensus.attempts?.let { MetricCertainty.EXACT } ?: cell.solvedAttemptsCertainty, - solvedAttended = consensus.attended ?: cell.solvedAttended, - solvedDailyPatrol = consensus.patrol ?: cell.solvedDailyPatrol, + solvedAttended = consensus.attended?.takeIf { it || finalized } + ?: cell.solvedAttended, + solvedDailyPatrol = consensus.patrol?.takeIf { it || finalized } + ?: cell.solvedDailyPatrol, ) } } @@ -356,40 +364,79 @@ internal object GunsmokeWeekSolver { val extensionCache = mutableMapOf>() val capturedCache = mutableMapOf>() val nextPartials = linkedMapOf() - - partials.forEach { partial -> - val previousCaptured = partial.captured - val maximumPreviousMerit = previousCaptured.merit + meritDelta - val resetCrossed = runStart == 0 && absoluteIndex == 1 - val requiredPreviousFinalMerit = if (resetCrossed) { - previousCaptured.merit + meritDelta - currentCheckpoint.member.weeklyMerit + val resetCrossed = runStart == 0 && absoluteIndex == 1 + val previousCell = cells[absoluteIndex - 1] + val highScore = currentCheckpoint.member.highScore + val tailMeritWindows = mutableMapOf, Bounds?>() + fun hasFixedTail(captured: ActivityState) = previousCell.hasClosingBoundary || + previousCell.hasFinalGunsmokeScore || + captured.attempts == ActivityInference.MAX_DAILY_ATTEMPTS + fun extensionMerits(captured: ActivityState): Pair { + val required = if (resetCrossed) { + captured.merit + meritDelta - currentCheckpoint.member.weeklyMerit } else { null } + return (required ?: captured.merit) to minOf( + required ?: (captured.merit + meritDelta), + maximumDailyMerit(highScore), + ) + } + fun intervalBounds() = resolveIntervalBounds( + runStart = runStart, + firstIndex = absoluteIndex - 1, + runEnd = runEnd, + partials = partials, + days = days, + zoneId = zoneId, + checkpoints = checkpoints, + cells = cells, + factsByDay = factsByDay, + bestEffortCells = bestEffortCells, + ) + + // Every open-tail score-merit value costs at least one search step. A transition + // that cannot fit the remaining budget goes straight to the interval bounds instead + // of spending the budget first. + val extensionWork = partials.asSequence() + .map(PartialSolution::captured) + .distinct() + .filterNot(::hasFixedTail) + .sumOf { captured -> + val (minimumMerit, maximumMerit) = extensionMerits(captured) + extensionRanges( + prefix = captured, + minimumMerit = minimumMerit, + maximumMerit = maximumMerit, + meritDelta = meritDelta, + scoreDelta = scoreDelta, + maximumAttemptScore = highScore, + tailMeritWindows = tailMeritWindows, + ).sumOf(ExtensionRange::span) + } + if (extensionWork > searchBudget.remaining) return intervalBounds() + + partials.forEach { partial -> + val previousCaptured = partial.captured val extensionStates = extensionCache.getOrPut(previousCaptured) { - if ( - cells[absoluteIndex - 1].hasClosingBoundary || - cells[absoluteIndex - 1].hasFinalGunsmokeScore || - previousCaptured.attempts == ActivityInference.MAX_DAILY_ATTEMPTS - ) { + if (hasFixedTail(previousCaptured)) { finalCandidates( captured = previousCaptured, - cell = cells[absoluteIndex - 1], + cell = previousCell, facts = previousFacts, ) } else { + val (minimumMerit, maximumMerit) = extensionMerits(previousCaptured) statesExtending( prefix = previousCaptured, - minimumMerit = requiredPreviousFinalMerit ?: previousCaptured.merit, - maximumMerit = minOf( - requiredPreviousFinalMerit ?: maximumPreviousMerit, - maximumDailyMerit(currentCheckpoint.member.highScore), - ), + minimumMerit = minimumMerit, + maximumMerit = maximumMerit, meritDelta = meritDelta, scoreDelta = scoreDelta, - maximumAttemptScore = currentCheckpoint.member.highScore, + maximumAttemptScore = highScore, searchBudget = searchBudget, - ) ?: return RunResolution(bestEffortCells, null, searchExhausted = true) + tailMeritWindows = tailMeritWindows, + ) ?: return intervalBounds() } } extensionStates.asSequence() @@ -456,11 +503,7 @@ internal object GunsmokeWeekSolver { ?.mergeHistory(candidate) ?: candidate if (nextPartials.size > MAX_PARTIAL_SOLUTIONS) { - return RunResolution( - bestEffortCells, - null, - searchExhausted = true, - ) + return intervalBounds() } } } @@ -558,6 +601,330 @@ internal object GunsmokeWeekSolver { return projected } + /** + * Continues a run whose detailed search would exceed its budget at the transition after + * [firstIndex]. Keeps the enumerated captured states of that checkpoint, then tracks every + * later capture only by its attempts, Login, and Daily Patrol, with merit and score ranges. + * Within such a class pair an interval splits between the previous day's tail and the next + * prefix in closed form, so every split the detailed search accepts is still admitted: the + * published lower bounds and shared states stay defensible, only less precise. Earlier days + * keep their detailed consensus over captured states that still have a complete path. Returns + * the run's cells; whole-week totals are left to the final-event aggregate. + */ + private fun resolveIntervalBounds( + runStart: Int, + firstIndex: Int, + runEnd: Int, + partials: List, + days: List, + zoneId: ZoneId, + checkpoints: List, + cells: List, + factsByDay: Map>, + bestEffortCells: List, + ): RunResolution { + val layers = mutableListOf( + partials.groupBy { it.captured.activityOption } + .mapValues { (_, group) -> IntervalRange.of(group.map(PartialSolution::captured)) }, + ) + val transitions = mutableListOf>() + var lastIndex = firstIndex + while (lastIndex < runEnd) { + val edges = intervalEdges( + from = layers.last(), + runStart = runStart, + absoluteIndex = lastIndex + 1, + days = days, + zoneId = zoneId, + checkpoints = checkpoints, + cells = cells, + factsByDay = factsByDay, + ) ?: break + if (edges.isEmpty()) return RunResolution(bestEffortCells, null) + transitions += edges + layers += edges.groupBy(IntervalEdge::to).mapValues { (_, incoming) -> + incoming.map(IntervalEdge::next).reduce(IntervalRange::hull) + } + lastIndex++ + } + // Decreasing counters stop the run early, as in the detailed search, and leave the last + // reachable capture an open prefix. + val truncated = lastIndex < runEnd + val lastCell = cells[lastIndex] + val lastFacts = factsByDay[days[lastIndex]].orEmpty() + val gunsmokeClosed = { option: ActivityOption -> + lastCell.hasClosingBoundary || + lastCell.hasFinalGunsmokeScore || + option.attempts == ActivityInference.MAX_DAILY_ATTEMPTS + } + val finals = layers.last().mapValues { (option, range) -> + when { + truncated -> listOf(option to range) + lastCell.hasClosingBoundary || !gunsmokeClosed(option) -> listOf(option to range) + else -> baselineCompletions(option).map { final -> + val extra = final.baselineMerit - option.baselineMerit + final to range.copy(merit = range.merit + Bounds.of(extra)) + } + }.filter { (final, finalRange) -> + truncated || + matchesFinalEvidence(final, finalRange.merit, finalRange.score, lastCell, lastFacts) + } + } + + val alive = MutableList(layers.size) { mutableSetOf() } + alive[layers.lastIndex] += finals.filterValues { it.isNotEmpty() }.keys + val liveTransitions = MutableList(transitions.size) { emptyList() } + for (layer in transitions.indices.reversed()) { + liveTransitions[layer] = transitions[layer].filter { it.to in alive[layer + 1] } + alive[layer] += liveTransitions[layer].map(IntervalEdge::from) + } + if (alive.first().isEmpty()) return RunResolution(bestEffortCells, null) + + val projected = projectPartialPrefix( + runStart = runStart, + currentIndex = firstIndex, + cells = cells, + checkpoints = checkpoints, + partials = partials.filter { it.captured.activityOption in alive.first() }, + ) + liveTransitions.forEachIndexed { layer, edges -> + val absoluteIndex = firstIndex + layer + projected[absoluteIndex] = project( + cell = cells[absoluteIndex], + summary = edges.map { edge -> + val prefix = layers[layer].getValue(edge.from) + StateSummary.of( + option = edge.final, + merit = prefix.merit + edge.tail.merit, + score = prefix.score + edge.tail.score, + ) + }.reduce(StateSummary::merge), + finalized = true, + observedAt = checkpoints[absoluteIndex]?.capturedAt, + ) + } + projected[lastIndex] = project( + cell = lastCell, + summary = alive.last().flatMap { option -> finals.getValue(option) } + .map { (final, range) -> StateSummary.of(final, range.merit, range.score) } + .reduce(StateSummary::merge), + finalized = !truncated && alive.last().all(gunsmokeClosed), + observedAt = checkpoints[lastIndex]?.capturedAt, + ) + return RunResolution(projected, null) + } + + /** + * Splits the counter interval ending at checkpoint [absoluteIndex] between each previous + * prefix class's tail and each next prefix class. The weekly counter fixes the tail only + * across the Monday reset; elsewhere it must move with the total counter. Returns the + * admissible edges, or null when the counters decrease. + */ + private fun intervalEdges( + from: Map, + runStart: Int, + absoluteIndex: Int, + days: List, + zoneId: ZoneId, + checkpoints: List, + cells: List, + factsByDay: Map>, + ): List? { + val current = requireNotNull(checkpoints[absoluteIndex]) + val previous = requireNotNull(checkpoints[absoluteIndex - 1]) + val meritDelta = current.member.totalMerit - previous.member.totalMerit + val scoreDelta = current.member.totalScore - previous.member.totalScore + if (meritDelta < 0L || scoreDelta < 0L) return null + val requiredTailMerit = if (runStart == 0 && absoluteIndex == 1) { + (meritDelta - current.member.weeklyMerit).takeIf { it >= 0L } ?: return emptyList() + } else { + if (current.member.weeklyMerit - previous.member.weeklyMerit != meritDelta) return emptyList() + null + } + val previousCell = cells[absoluteIndex - 1] + val previousFacts = factsByDay[days[absoluteIndex - 1]].orEmpty() + val currentFacts = factsByDay[days[absoluteIndex]].orEmpty() + // A cell measured from an exact opening boundary fixes the new prefix, and with it the tail. + val currentCell = cells[absoluteIndex] + val direct = currentCell.meritDelta?.takeIf { currentCell.manualOverride == null }?.let { merit -> + currentCell.scoreDelta?.let { score -> Metrics(merit, score) } + } + val directOptions = direct?.let { + statesForMetrics(it.merit, it.score, current.member.highScore).map { state -> state.activityOption } + } + val nextOptions = ALL_ACTIVITY_OPTIONS.filter { option -> + (directOptions == null || option in directOptions) && + matchesCapturedEvidence(option, current, days[absoluteIndex], zoneId, currentFacts) + } + return buildList { + from.forEach { (prefix, prefixRange) -> + tailFinals(prefix, previousCell).forEach { final -> + nextOptions.forEach { next -> + var tail = splitInterval( + prefix = prefix, + final = final, + next = next, + meritDelta = meritDelta, + scoreDelta = scoreDelta, + maximumAttemptScore = current.member.highScore, + requiredTailMerit = requiredTailMerit, + ) ?: return@forEach + if (direct != null) { + val exact = IntervalRange( + merit = Bounds.of(meritDelta - direct.merit), + score = Bounds.of(scoreDelta - direct.score), + ) + if (exact.merit.minimum !in tail.merit || exact.score.minimum !in tail.score) { + return@forEach + } + tail = exact + } + if ( + matchesFinalEvidence( + option = final, + merit = prefixRange.merit + tail.merit, + score = prefixRange.score + tail.score, + cell = previousCell, + facts = previousFacts, + ) + ) { + add( + IntervalEdge( + from = prefix, + final = final, + to = next, + tail = tail, + next = IntervalRange( + merit = tail.merit.subtractedFrom(meritDelta), + score = tail.score.subtractedFrom(scoreDelta), + ), + ), + ) + } + } + } + } + } + } + + /** Final classes a captured prefix class can still reach before its day closes. */ + private fun tailFinals( + prefix: ActivityOption, + cell: WeeklyReportBuilder.DayCell, + ): List { + if (cell.hasClosingBoundary) return listOf(prefix) + val gunsmokeClosed = cell.hasFinalGunsmokeScore || + prefix.attempts == ActivityInference.MAX_DAILY_ATTEMPTS + val maximumAttempts = if (gunsmokeClosed) prefix.attempts else ActivityInference.MAX_DAILY_ATTEMPTS + return baselineCompletions(prefix).flatMap { completed -> + (prefix.attempts..maximumAttempts).mapNotNull { attempts -> + completed.copy(attempts = attempts).takeIf { attempts == 0 || it.attended } + } + } + } + + /** The captured class with every Login and Daily Patrol completion it can still gain. */ + private fun baselineCompletions(option: ActivityOption): List = + ACTIVITY_BASELINES.filter { baseline -> + (baseline.attended || !option.attended) && (baseline.patrol || !option.patrol) + }.map { baseline -> option.copy(attended = baseline.attended, patrol = baseline.patrol) } + + /** + * Splits one interval between a previous-day tail ([prefix] to [final]) and the [next] prefix. + * Both pieces share the interval's score merit and score; each attempt adds at most nine + * rounding points and at most the captured high score, which bounds how much the tail can + * take. Returns the tail's merit and score ranges, or null when no split fits. + */ + private fun splitInterval( + prefix: ActivityOption, + final: ActivityOption, + next: ActivityOption, + meritDelta: Long, + scoreDelta: Long, + maximumAttemptScore: Long, + requiredTailMerit: Long?, + ): IntervalRange? { + val tailAttempts = final.attempts - prefix.attempts + val tailFixedMerit = final.baselineMerit - prefix.baselineMerit + + tailAttempts * ActivityInference.MERIT_PER_ATTEMPT + val sharedScoreMerit = meritDelta - tailFixedMerit - + next.baselineMerit - next.attempts * ActivityInference.MERIT_PER_ATTEMPT + if (sharedScoreMerit < 0L) return null + if (sharedScoreMerit > Long.MAX_VALUE / ActivityInference.SCORE_POINTS_PER_MERIT) return null + val remainder = scoreDelta - sharedScoreMerit * ActivityInference.SCORE_POINTS_PER_MERIT + if (remainder < 0L || remainder > MAX_ROUNDING_REMAINDER * (tailAttempts + next.attempts)) return null + if ( + tailAttempts == 0 && + !ActivityInference.isCompatibleScore(scoreDelta, sharedScoreMerit, next.attempts, maximumAttemptScore) + ) { + return null + } + if ( + next.attempts == 0 && + !ActivityInference.isCompatibleScore(scoreDelta, sharedScoreMerit, tailAttempts, maximumAttemptScore) + ) { + return null + } + val attemptScoreMerit = if (maximumAttemptScore > 0L) { + maximumAttemptScore / ActivityInference.SCORE_POINTS_PER_MERIT + } else { + Long.MAX_VALUE + } + var lowest = maxOf(0L, sharedScoreMerit - saturatedMultiply(attemptScoreMerit, next.attempts)) + var highest = minOf(sharedScoreMerit, saturatedMultiply(attemptScoreMerit, tailAttempts)) + if (requiredTailMerit != null) { + val required = requiredTailMerit - tailFixedMerit + if (required !in lowest..highest) return null + lowest = required + highest = required + } + // An attempt at the high score's quotient keeps at most its remainder, so the shared + // remainder can fail to fit near either end of the score-merit range. + fun remaindersFit(tailScoreMerit: Long) = remainder <= + maximumRoundingRemainder(tailScoreMerit, tailAttempts, maximumAttemptScore) + + maximumRoundingRemainder(sharedScoreMerit - tailScoreMerit, next.attempts, maximumAttemptScore) + var steps = 0 + while (lowest <= highest && !remaindersFit(highest) && steps++ < MAX_REMAINDER_STEPS) highest-- + steps = 0 + while (lowest <= highest && !remaindersFit(lowest) && steps++ < MAX_REMAINDER_STEPS) lowest++ + if (lowest > highest) return null + var lowestScore = lowest * ActivityInference.SCORE_POINTS_PER_MERIT + maxOf( + 0L, + remainder - maximumRoundingRemainder(sharedScoreMerit - lowest, next.attempts, maximumAttemptScore), + ) + var highestScore = highest * ActivityInference.SCORE_POINTS_PER_MERIT + minOf( + maximumRoundingRemainder(highest, tailAttempts, maximumAttemptScore), + remainder, + ) + if (maximumAttemptScore > 0L) { + lowestScore = maxOf(lowestScore, scoreDelta - saturatedMultiply(maximumAttemptScore, next.attempts)) + highestScore = minOf(highestScore, saturatedMultiply(maximumAttemptScore, tailAttempts)) + } + if (maxOf(0L, lowestScore) > minOf(scoreDelta, highestScore)) return null + return IntervalRange( + merit = Bounds(tailFixedMerit + lowest, tailFixedMerit + highest), + score = Bounds(maxOf(0L, lowestScore), minOf(scoreDelta, highestScore)), + ) + } + + /** + * The largest total below-ten score remainder [attempts] can carry while their rounded score + * merits sum to [scoreMerit]. Attempts forced up to the high score's quotient keep at most + * the high score's own remainder. + */ + private fun maximumRoundingRemainder( + scoreMerit: Long, + attempts: Int, + maximumAttemptScore: Long, + ): Long { + if (attempts == 0) return 0L + if (maximumAttemptScore <= 0L) return MAX_ROUNDING_REMAINDER * attempts + val quotient = maximumAttemptScore / ActivityInference.SCORE_POINTS_PER_MERIT + val capped = (scoreMerit - attempts * (quotient - 1L)).coerceIn(0L, attempts.toLong()) + return MAX_ROUNDING_REMAINDER * (attempts - capped) + + maximumAttemptScore % ActivityInference.SCORE_POINTS_PER_MERIT * capped + } + /** Derives a monotonic weekly-attempt floor from the cumulative event score. */ private fun weeklyAttemptsFloor(checkpoints: List): Int? { if (checkpoints.firstOrNull() == null) return null @@ -703,85 +1070,140 @@ internal object GunsmokeWeekSolver { scoreDelta: Long, maximumAttemptScore: Long, searchBudget: SearchBudget, + tailMeritWindows: MutableMap, Bounds?> = mutableMapOf(), ): List? { val effectiveMinimumMerit = maxOf(prefix.merit, minimumMerit) if (maximumMerit < effectiveMinimumMerit || meritDelta < 0L || scoreDelta < 0L) { return emptyList() } val states = linkedSetOf() - var examinedScoreMerits = 0L - for (attempts in prefix.attempts..ActivityInference.MAX_DAILY_ATTEMPTS) { - for (baseline in ACTIVITY_BASELINES) { - if (attempts > 0 && !baseline.attended) continue - if (prefix.attended && !baseline.attended) continue - if (prefix.patrol && !baseline.patrol) continue - val fixedMerit = baseline.merit + - attempts * ActivityInference.MERIT_PER_ATTEMPT - if (attempts == prefix.attempts) { - val merit = fixedMerit + prefix.scoreMerit - if (merit !in effectiveMinimumMerit..maximumMerit) continue + for (baseline in ACTIVITY_BASELINES) { + if (prefix.attempts > 0 && !baseline.attended) continue + if (prefix.attended && !baseline.attended) continue + if (prefix.patrol && !baseline.patrol) continue + val merit = baseline.merit + + prefix.attempts * ActivityInference.MERIT_PER_ATTEMPT + + prefix.scoreMerit + if (merit !in effectiveMinimumMerit..maximumMerit) continue + val state = ActivityState( + merit = merit, + score = prefix.score, + attempts = prefix.attempts, + scoreMerit = prefix.scoreMerit, + attended = baseline.attended, + patrol = baseline.patrol, + ) + val currentMerit = meritDelta - (state.merit - prefix.merit) + val currentScore = scoreDelta - (state.score - prefix.score) + if ( + extends(prefix, state, maximumAttemptScore) && + statesForMetrics(currentMerit, currentScore, maximumAttemptScore).isNotEmpty() + ) { + states += state + } + } + val ranges = extensionRanges( + prefix = prefix, + minimumMerit = minimumMerit, + maximumMerit = maximumMerit, + meritDelta = meritDelta, + scoreDelta = scoreDelta, + maximumAttemptScore = maximumAttemptScore, + tailMeritWindows = tailMeritWindows, + ) + if (ranges.sumOf(ExtensionRange::span) > MAX_SCORE_MERIT_VALUES) return null + ranges.forEach { range -> + for (scoreMerit in range.minimumScoreMerit..range.maximumScoreMerit) { + if (!searchBudget.consume()) return null + val merit = range.fixedMerit + scoreMerit + val currentMerit = meritDelta - (merit - prefix.merit) + if (currentMerit < 0L) continue + statesForMerit(currentMerit, maximumAttemptScore).forEach { current -> + if (!searchBudget.consume()) return null + val score = prefix.score + scoreDelta - current.score val state = ActivityState( merit = merit, - score = prefix.score, - attempts = attempts, - scoreMerit = prefix.scoreMerit, - attended = baseline.attended, - patrol = baseline.patrol, + score = score, + attempts = range.attempts, + scoreMerit = scoreMerit, + attended = true, + patrol = range.baseline.patrol, ) - val currentMerit = meritDelta - (state.merit - prefix.merit) - val currentScore = scoreDelta - (state.score - prefix.score) - if ( - extends(prefix, state, maximumAttemptScore) && - statesForMetrics(currentMerit, currentScore, maximumAttemptScore).isNotEmpty() - ) { - states += state - } - continue + if (extends(prefix, state, maximumAttemptScore)) states += state + if (states.size > MAX_EXTENSION_STATES) return null } - val minimumScoreMerit = maxOf( - 0L, - prefix.scoreMerit, - effectiveMinimumMerit - fixedMerit, - ) - val maximumScoreMerit = minOf( - if (maximumAttemptScore > 0L) { - saturatedMultiply( - maximumAttemptScore / ActivityInference.SCORE_POINTS_PER_MERIT, - attempts, - ) - } else { - maximumMerit - fixedMerit - }, - maximumMerit - fixedMerit, - ) - if (maximumScoreMerit < minimumScoreMerit) continue - val span = maximumScoreMerit - minimumScoreMerit + 1L - examinedScoreMerits += span - if (examinedScoreMerits > MAX_SCORE_MERIT_VALUES) return null + } + } + return states.toList() + } - for (scoreMerit in minimumScoreMerit..maximumScoreMerit) { - if (!searchBudget.consume()) return null - val merit = fixedMerit + scoreMerit - val currentMerit = meritDelta - (merit - prefix.merit) - if (currentMerit < 0L) continue - statesForMerit(currentMerit, maximumAttemptScore).forEach { current -> - if (!searchBudget.consume()) return null - val score = prefix.score + scoreDelta - current.score - val state = ActivityState( - merit = merit, - score = score, - attempts = attempts, - scoreMerit = scoreMerit, - attended = true, - patrol = baseline.patrol, - ) - if (extends(prefix, state, maximumAttemptScore)) states += state - if (states.size > MAX_EXTENSION_STATES) return null + /** + * Lists the score-merit values a previous-day tail with additional attempts can add, per final + * attempt count and baseline. The tail and the next prefix share the interval's score merit + * and rounding remainders, so only the tail merits some next prefix class can complement are + * listed; every skipped value would fail the detailed checks anyway. Shared by the detailed + * search and its budget estimate. + */ + private fun extensionRanges( + prefix: ActivityState, + minimumMerit: Long, + maximumMerit: Long, + meritDelta: Long, + scoreDelta: Long, + maximumAttemptScore: Long, + tailMeritWindows: MutableMap, Bounds?> = mutableMapOf(), + ): List { + val effectiveMinimumMerit = maxOf(prefix.merit, minimumMerit) + if (maximumMerit < effectiveMinimumMerit) return emptyList() + return buildList { + for (attempts in prefix.attempts + 1..ActivityInference.MAX_DAILY_ATTEMPTS) { + for (baseline in ACTIVITY_BASELINES) { + if (!baseline.attended) continue + if (prefix.patrol && !baseline.patrol) continue + val final = ActivityOption(attempts, baseline.attended, baseline.patrol) + // The window depends only on the classes, so one transition shares it. + val windowKey = prefix.activityOption to final + val tailMerits = if (windowKey in tailMeritWindows) { + tailMeritWindows[windowKey] + } else { + ALL_ACTIVITY_OPTIONS.mapNotNull { next -> + splitInterval( + prefix = prefix.activityOption, + final = final, + next = next, + meritDelta = meritDelta, + scoreDelta = scoreDelta, + maximumAttemptScore = maximumAttemptScore, + requiredTailMerit = null, + )?.merit + }.reduceOrNull(Bounds::hull).also { tailMeritWindows[windowKey] = it } + } ?: continue + val fixedMerit = baseline.merit + + attempts * ActivityInference.MERIT_PER_ATTEMPT + val minimumScoreMerit = maxOf( + 0L, + prefix.scoreMerit, + effectiveMinimumMerit - fixedMerit, + prefix.merit + tailMerits.minimum - fixedMerit, + ) + val maximumScoreMerit = minOf( + if (maximumAttemptScore > 0L) { + saturatedMultiply( + maximumAttemptScore / ActivityInference.SCORE_POINTS_PER_MERIT, + attempts, + ) + } else { + maximumMerit - fixedMerit + }, + maximumMerit - fixedMerit, + prefix.merit + tailMerits.maximum - fixedMerit, + ) + if (maximumScoreMerit >= minimumScoreMerit) { + add(ExtensionRange(attempts, baseline, fixedMerit, minimumScoreMerit, maximumScoreMerit)) } } } } - return states.toList() } /** Enumerates the narrow score range compatible with a fixed merit state. */ @@ -894,16 +1316,24 @@ internal object GunsmokeWeekSolver { day: LocalDate, zoneId: ZoneId, facts: List, + ): Boolean = matchesMaximumAttemptScore(state, checkpoint.member.highScore) && + matchesCapturedEvidence(state.activityOption, checkpoint, day, zoneId, facts) + + private fun matchesCapturedEvidence( + option: ActivityOption, + checkpoint: Checkpoint, + day: LocalDate, + zoneId: ZoneId, + facts: List, ): Boolean { - if (!matchesMaximumAttemptScore(state, checkpoint.member.highScore)) return false val patrolBeforeCapture = facts.any { !it.occurredAt.isAfter(checkpoint.capturedAt) } - if (patrolBeforeCapture && !state.patrol) return false + if (patrolBeforeCapture && !option.patrol) return false val loginAt = checkpoint.member.lastLogin.takeIf { it > 0L }?.let(Instant::ofEpochSecond) if ( loginAt != null && !loginAt.isAfter(checkpoint.capturedAt) && PlatoonPeriods.gameDay(loginAt, zoneId) == day && - !state.attended + !option.attended ) { return false } @@ -952,15 +1382,30 @@ internal object GunsmokeWeekSolver { state: ActivityState, cell: WeeklyReportBuilder.DayCell, facts: List, + ): Boolean = matchesFinalEvidence( + option = state.activityOption, + merit = Bounds.of(state.merit), + score = Bounds.of(state.score), + cell = cell, + facts = facts, + ) + + /** Accepts a final class whose merit and score ranges can still hold a manual value. */ + private fun matchesFinalEvidence( + option: ActivityOption, + merit: Bounds, + score: Bounds, + cell: WeeklyReportBuilder.DayCell, + facts: List, ): Boolean { - if (facts.isNotEmpty() && !state.patrol) return false - if (cell.hasLoginFact && !state.attended) return false + if (facts.isNotEmpty() && !option.patrol) return false + if (cell.hasLoginFact && !option.attended) return false val override = cell.manualOverride ?: return true - return (override.meritDelta == null || override.meritDelta == state.merit) && - (override.scoreDelta == null || override.scoreDelta == state.score) && - (override.attempts == null || override.attempts == state.attempts) && - (override.attended == null || override.attended == state.attended) && - (override.dailyPatrol == null || override.dailyPatrol == state.patrol) + return (override.meritDelta == null || override.meritDelta in merit) && + (override.scoreDelta == null || override.scoreDelta in score) && + (override.attempts == null || override.attempts == option.attempts) && + (override.attended == null || override.attended == option.attended) && + (override.dailyPatrol == null || override.dailyPatrol == option.patrol) } private fun extends( @@ -995,27 +1440,31 @@ internal object GunsmokeWeekSolver { states: List, finalized: Boolean, observedAt: Instant?, + ): WeeklyReportBuilder.DayCell = project(cell, StateSummary.of(states), finalized, observedAt) + + private fun project( + cell: WeeklyReportBuilder.DayCell, + summary: StateSummary, + finalized: Boolean, + observedAt: Instant?, ): WeeklyReportBuilder.DayCell { - val merits = states.map(ActivityState::merit).distinct() - val scores = states.map(ActivityState::score).distinct() - val attempts = states.map(ActivityState::attempts).distinct() - val attendedConsensus = states.map(ActivityState::attended).distinct().singleOrNull() - val patrolConsensus = states.map(ActivityState::patrol).distinct().singleOrNull() + val attendedConsensus = summary.attended.singleOrNull() + val patrolConsensus = summary.patrol.singleOrNull() val activityFactMeritFloor = when { cell.hasDailyPatrolFact -> LOGIN_MERIT + PATROL_MERIT cell.hasLoginFact -> LOGIN_MERIT else -> 0L } - val merit = cell.manualOverride?.meritDelta ?: merits.minOrNull()?.let { inferred -> + val merit = cell.manualOverride?.meritDelta ?: summary.merit?.minimum?.let { inferred -> maxOf(inferred, activityFactMeritFloor) } - val score = cell.manualOverride?.scoreDelta ?: scores.minOrNull() - val attemptValue = cell.manualOverride?.attempts ?: attempts.minOrNull() + val score = cell.manualOverride?.scoreDelta ?: summary.score?.minimum + val attemptValue = cell.manualOverride?.attempts ?: summary.attempts.minOrNull() val exactScore = (finalized || attemptValue == ActivityInference.MAX_DAILY_ATTEMPTS || cell.hasFinalGunsmokeScore) && - scores.size == 1 + summary.score?.isPoint == true val exactAttempts = (finalized || attemptValue == ActivityInference.MAX_DAILY_ATTEMPTS || cell.hasFinalGunsmokeScore) && - attempts.size == 1 - val exactMerit = merits.size == 1 && + summary.attempts.size == 1 + val exactMerit = summary.merit?.isPoint == true && (finalized || (exactScore && exactAttempts && @@ -1054,17 +1503,10 @@ internal object GunsmokeWeekSolver { else -> MetricCertainty.LOWER_BOUND } - private fun reportDay(instant: Instant, days: List, zoneId: ZoneId): LocalDate { - val gameDay = PlatoonPeriods.gameDay(instant, zoneId) - return if ( - gameDay > days.first() && - instant == PlatoonPeriods.periodStartInstant(gameDay, zoneId) - ) { - gameDay.minusDays(1) - } else { - gameDay - } - } + /** A reset capture closes the previous day, except the week's own opening capture. */ + private fun reportDay(instant: Instant, days: List, zoneId: ZoneId): LocalDate = + PlatoonPeriods.checkpointDay(instant, zoneId).takeUnless { it < days.first() } + ?: PlatoonPeriods.gameDay(instant, zoneId) private fun SnapshotMember.hasValidActivityCounters(): Boolean = weeklyMerit >= 0L && totalMerit >= 0L && highScore >= 0L && totalScore >= 0L @@ -1124,7 +1566,6 @@ internal object GunsmokeWeekSolver { private data class RunResolution( val cells: List, val totals: WeeklyReportBuilder.ResolvedGunsmokeTotals?, - val searchExhausted: Boolean = false, ) private data class ActivityTotalBounds( @@ -1232,7 +1673,102 @@ internal object GunsmokeWeekSolver { val lastDayFinalized: Boolean, ) - private class SearchBudget(private var remaining: Int) { + private val ActivityState.activityOption: ActivityOption + get() = ActivityOption(attempts, attended, patrol) + + /** An inclusive range of one counter value. */ + private data class Bounds(val minimum: Long, val maximum: Long) { + val isPoint: Boolean + get() = minimum == maximum + + operator fun contains(value: Long): Boolean = value in minimum..maximum + + operator fun plus(other: Bounds): Bounds = Bounds( + saturatedAdd(minimum, other.minimum), + saturatedAdd(maximum, other.maximum), + ) + + fun hull(other: Bounds): Bounds = + Bounds(minOf(minimum, other.minimum), maxOf(maximum, other.maximum)) + + fun subtractedFrom(total: Long): Bounds = Bounds(total - maximum, total - minimum) + + companion object { + fun of(value: Long): Bounds = Bounds(value, value) + } + } + + private data class IntervalRange(val merit: Bounds, val score: Bounds) { + fun hull(other: IntervalRange): IntervalRange = + IntervalRange(merit.hull(other.merit), score.hull(other.score)) + + companion object { + fun of(states: List): IntervalRange = IntervalRange( + merit = Bounds(states.minOf(ActivityState::merit), states.maxOf(ActivityState::merit)), + score = Bounds(states.minOf(ActivityState::score), states.maxOf(ActivityState::score)), + ) + } + } + + /** One way to split an interval: a prefix class, its final class, and the next prefix. */ + private data class IntervalEdge( + val from: ActivityOption, + val final: ActivityOption, + val to: ActivityOption, + val tail: IntervalRange, + val next: IntervalRange, + ) + + /** What every compatible state of one day shares, for projection into its cell. */ + private data class StateSummary( + val merit: Bounds?, + val score: Bounds?, + val attempts: Set, + val attended: Set, + val patrol: Set, + ) { + fun merge(other: StateSummary): StateSummary = StateSummary( + merit = merit?.let { other.merit?.let(it::hull) ?: it } ?: other.merit, + score = score?.let { other.score?.let(it::hull) ?: it } ?: other.score, + attempts = attempts + other.attempts, + attended = attended + other.attended, + patrol = patrol + other.patrol, + ) + + companion object { + fun of(states: List): StateSummary = StateSummary( + merit = states.takeIf { it.isNotEmpty() }?.let { IntervalRange.of(it).merit }, + score = states.takeIf { it.isNotEmpty() }?.let { IntervalRange.of(it).score }, + attempts = states.map(ActivityState::attempts).toSet(), + attended = states.map(ActivityState::attended).toSet(), + patrol = states.map(ActivityState::patrol).toSet(), + ) + + fun of(option: ActivityOption, merit: Bounds, score: Bounds): StateSummary = StateSummary( + merit = merit, + score = score, + attempts = setOf(option.attempts), + attended = setOf(option.attended), + patrol = setOf(option.patrol), + ) + } + } + + private data class ExtensionRange( + val attempts: Int, + val baseline: ActivityBaseline, + val fixedMerit: Long, + val minimumScoreMerit: Long, + val maximumScoreMerit: Long, + ) { + val span: Long + get() = maximumScoreMerit - minimumScoreMerit + 1L + } + + private class SearchBudget(remaining: Int) { + var remaining: Int = remaining + private set + fun consume(): Boolean { if (remaining <= 0) return false remaining -= 1 @@ -1307,10 +1843,18 @@ internal object GunsmokeWeekSolver { (cell.solvedDailyPatrol == null || cell.solvedDailyPatrol == option.patrol) } + private val ALL_ACTIVITY_OPTIONS: List = + (0..ActivityInference.MAX_DAILY_ATTEMPTS).flatMap { attempts -> + ACTIVITY_BASELINES.filter { attempts == 0 || it.attended } + .map { ActivityOption(attempts, it.attended, it.patrol) } + } + private val MAX_BOUNDARY_DISTANCE: Duration = Duration.ofMinutes(15) + private const val MAX_ROUNDING_REMAINDER = ActivityInference.SCORE_POINTS_PER_MERIT - 1L + private const val MAX_REMAINDER_STEPS = 64 private const val MAX_SCORE_MERIT_VALUES = 250_000L private const val MAX_EXTENSION_STATES = 100_000 - private const val MAX_PARTIAL_SOLUTIONS = 2_000 + private const val MAX_PARTIAL_SOLUTIONS = 256 private const val MAX_SEARCH_OPERATIONS = 100_000 private const val MAX_AGGREGATE_SEARCH_OPERATIONS = 100_000 private const val LOGIN_MERIT = ActivityInference.LOGIN_MERIT diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipCorrelationWindow.kt b/app/src/main/java/dev/gf2log/app/management/MembershipCorrelationWindow.kt new file mode 100644 index 0000000..15e0fc3 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/MembershipCorrelationWindow.kt @@ -0,0 +1,25 @@ +package dev.gf2log.app.management + +import java.time.Instant + +/** + * Decides which activity fact may date an inferred roster join or withdrawal. The change + * happened after the previous roster and no later than the roster that revealed it, so only an + * activity in that interval can mark it; a later activity happened while a roster already + * showed the result. The rulebook defines no 21935 action that marks membership, so the + * interval is the only evidence; the lead bound keeps a sparse roster gap from adopting a + * distant fact. + */ +internal object MembershipCorrelationWindow { + const val MAX_LEAD_MILLIS = 12L * 60L * 60L * 1000L + + /** + * Whether an activity at [occurredAt] can date a boundary that the roster at [inferredAt] + * revealed. Without a [previousRosterAt] the change has no lower bound and stays inferred. + */ + fun admits(occurredAt: Instant, inferredAt: Instant, previousRosterAt: Instant?): Boolean = + previousRosterAt != null && + occurredAt.isAfter(previousRosterAt) && + !occurredAt.isAfter(inferredAt) && + !occurredAt.isBefore(inferredAt.minusMillis(MAX_LEAD_MILLIS)) +} diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt index 691c362..9b2e551 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -85,12 +85,6 @@ internal object MembershipEditing { "(SELECT id FROM member_events WHERE uid = ?)", arrayOf(uid.toString(), uid.toString()), ) - db.delete( - "weekly_notes", - "is_automatic = 1 AND event_id IN " + - "(SELECT id FROM member_events WHERE uid = ?)", - uidArgument, - ) db.delete("member_events", "uid = ?", uidArgument) db.delete("membership_periods", "uid = ?", uidArgument) db.delete("snapshot_members", "uid = ?", uidArgument) diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt b/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt index 20a93a6..359df7d 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt @@ -192,16 +192,13 @@ internal object MembershipRows { ) } - /** Deletes the selected events after detaching every note and activity fact citing them. */ + /** + * Deletes the selected events after detaching every activity fact citing them. Weekly notes + * never cite events: notes are manual and keep a NULL event_id, whose foreign key would + * also clear on delete. + */ fun deleteEvents(db: SQLiteDatabase, selection: String, arguments: Array) { val selectedIds = "SELECT id FROM member_events WHERE $selection" - db.delete("weekly_notes", "is_automatic = 1 AND event_id IN ($selectedIds)", arguments) - db.update( - "weekly_notes", - ContentValues().apply { putNull("event_id") }, - "event_id IN ($selectedIds)", - arguments, - ) db.update( "platoon_activity", ContentValues().apply { putNull("member_event_id") }, diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt index dac6f2c..0ca5ba5 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -87,9 +87,11 @@ class PlatoonBackupManager internal constructor( /** * Restores a same-device, current-schema checkpoint without retiring retained CSV evidence. - * Used only after the caller removes deterministic files planned by the reverted import. + * Used only after the caller removes deterministic files planned by the reverted import. With + * [expectedDatabaseSha256], the live database must still match it inside the exclusive section + * that installs the checkpoint; otherwise nothing is replaced. */ - internal fun restoreCheckpoint(input: InputStream) = + internal fun restoreCheckpoint(input: InputStream, expectedDatabaseSha256: String? = null) = withStagedArchive(input) { stagedDatabase, staged -> validateSelectedBackup { BackupFormatPolicy.requirePlatoonOnly( @@ -99,7 +101,12 @@ class PlatoonBackupManager internal constructor( requireArchiveMatchesScope(staged) validateDatabase(stagedDatabase, requireCurrentSchema = true) } - replaceRestoredState(stagedDatabase, restoredSettings = null, retireRetainedCsv = false) + replaceRestoredState( + stagedDatabase, + restoredSettings = null, + retireRetainedCsv = false, + expectedDatabaseSha256 = expectedDatabaseSha256, + ) } fun restoreFull(input: InputStream) = withStagedArchive(input) { stagedDatabase, staged -> @@ -241,18 +248,27 @@ class PlatoonBackupManager internal constructor( /** * Replaces the validated database and retires target-device roster CSV evidence atomically. * Replaces settings only for a complete backup while preserving them for a v1 backup. Uses the - * durable restore journal so a process death or failure rolls every resource back. + * durable restore journal so a process death or failure rolls every resource back. The + * rollback runs inside the same exclusive section, so no ingest can land between a failed + * install and its rollback. */ private fun replaceRestoredState( stagedDatabase: File, restoredSettings: AppBackupSettings?, retireRetainedCsv: Boolean = true, restoreTarget: RestoreTarget? = null, + expectedDatabaseSha256: String? = null, ) { val retainedCsvDirectory = storageScope.retainedCsvDirectory(appContext) val previousRetainedCsvDirectory = storageScope.previousRetainedCsvDirectory(appContext) - try { - PlatoonRepository.withExclusiveDatabase(storageScope) { + PlatoonRepository.withExclusiveDatabase(storageScope) { + if (expectedDatabaseSha256 != null) { + ensureDatabaseExists() + check(databaseFile.sha256Hex() == expectedDatabaseSha256) { + "Platoon data changed after the CSV import; undo would overwrite newer changes" + } + } + try { beginRestoreTransaction( previousSettings = if (restoredSettings == null) { null @@ -285,14 +301,14 @@ class PlatoonBackupManager internal constructor( previousRetainedCsvDirectory, previousDatabaseFile(appContext, storageScope), ) + } catch (error: Exception) { + runCatching { + recoverInterruptedFullRestore(appContext, settingsStore, storageScope) + } + .exceptionOrNull() + ?.let(error::addSuppressed) + throw error } - } catch (error: Exception) { - runCatching { - recoverInterruptedFullRestore(appContext, settingsStore, storageScope) - } - .exceptionOrNull() - ?.let(error::addSuppressed) - throw error } } @@ -364,16 +380,27 @@ class PlatoonBackupManager internal constructor( private fun replaceDatabase(staged: File, preservePrevious: Boolean = false) { databaseFile.parentFile?.mkdirs() val previous = previousDatabaseFile(appContext, storageScope) - if (previous.exists() && !previous.delete()) error("Unable to clear previous restore backup") - listOf("-wal", "-shm", "-journal").forEach { suffix -> - File(databaseFile.path + suffix).delete() - } - if (databaseFile.exists() && !databaseFile.renameTo(previous)) { - error("Unable to preserve the current Platoon database") + if (!deletePreviousDatabase(previous)) error("Unable to clear previous restore backup") + if (databaseFile.exists()) { + // A database not opened since a crash can still hold committed WAL frames or a hot + // rollback journal. Those move with the rollback copy, before its main file, so + // putting the copy back never loses or half-applies a transaction. + preservedSidecars(databaseFile).zip(preservedSidecars(previous)).forEach { (live, kept) -> + if (live.exists() && !live.renameTo(kept)) { + error("Unable to preserve the current Platoon database") + } + } + File(databaseFile.path + "-shm").delete() + if (!databaseFile.renameTo(previous)) { + error("Unable to preserve the current Platoon database") + } + } else { + // Without a database these are orphans that must never pair with the restored file. + databaseSidecars(databaseFile).forEach(File::delete) } val installed = staged.renameTo(databaseFile) if (!installed) { - previous.renameTo(databaseFile) + restorePreviousDatabase(databaseFile, previous) error("Unable to install the restored Platoon database") } try { @@ -389,16 +416,19 @@ class PlatoonBackupManager internal constructor( } } validateDatabase(databaseFile, requireCurrentSchema = true) - if (!preservePrevious && previous.exists() && !previous.delete()) { + if (!preservePrevious && !deletePreviousDatabase(previous)) { error("Unable to discard the previous Platoon database") } } catch (error: Exception) { - listOf("", "-wal", "-shm", "-journal").forEach { suffix -> - File(databaseFile.path + suffix).delete() - } - if (previous.exists() && !previous.renameTo(databaseFile)) { + runCatching { + if (previous.exists()) { + restorePreviousDatabase(databaseFile, previous) + } else { + databaseSidecars(databaseFile).forEach(File::delete) + } + }.onFailure { error.addSuppressed( - IllegalStateException("Unable to restore the previous Platoon database"), + IllegalStateException("Unable to restore the previous Platoon database", it), ) } throw error @@ -463,7 +493,7 @@ class PlatoonBackupManager internal constructor( if (previousCsv.exists() && !previousCsv.deleteRecursively()) { return } - if (previousDatabase.exists() && !previousDatabase.delete()) return + if (!deletePreviousDatabase(previousDatabase)) return cleanupRestoreTransaction(appContext, storageScope) } @@ -628,7 +658,7 @@ class PlatoonBackupManager internal constructor( ) RestoreState.COMMITTED -> { if (previousCsv.exists() && !previousCsv.deleteRecursively()) return@withExclusiveDatabase - if (previousDatabase.exists() && !previousDatabase.delete()) return@withExclusiveDatabase + if (!deletePreviousDatabase(previousDatabase)) return@withExclusiveDatabase cleanupRestoreTransaction(appContext, scope) } } @@ -644,14 +674,8 @@ class PlatoonBackupManager internal constructor( previousCsv: File, scope: PlatoonStorageScope, ) { - if (previousDatabase.exists()) { - databaseSidecars(database).forEach(File::delete) - if (database.exists() && !database.delete()) { - error("Unable to remove the interrupted restored database") - } - if (!previousDatabase.renameTo(database)) { - error("Unable to recover the previous Platoon database") - } + if (previousDatabase.exists() || preservedSidecars(previousDatabase).any(File::exists)) { + restorePreviousDatabase(database, previousDatabase) } else if (restoreDatabaseWasMissingFile(context, scope).isFile) { databaseSidecars(database).forEach { file -> if (file.exists() && !file.delete()) { @@ -798,6 +822,40 @@ class PlatoonBackupManager internal constructor( private fun databaseSidecars(database: File): List = listOf("", "-wal", "-shm", "-journal").map { suffix -> File(database.path + suffix) } + + /** The WAL and rollback journal: the only sidecars that can hold state the file lacks. */ + private fun preservedSidecars(database: File): List = + listOf("-wal", "-journal").map { suffix -> File(database.path + suffix) } + + /** + * Puts a rollback copy and its preserved sidecars back at [database]. The main copy moves + * first and its sidecars last, the reverse of how they were set aside, so while the main + * copy exists everything at [database] belongs to the replacement, and a copy without its + * main file has only sidecars left to return. Safe to repeat after a process death. + */ + private fun restorePreviousDatabase(database: File, previous: File) { + if (previous.exists()) { + databaseSidecars(database).forEach { file -> + if (file.exists() && !file.delete()) { + error("Unable to remove the interrupted restored database") + } + } + if (!previous.renameTo(database)) { + error("Unable to recover the previous Platoon database") + } + } + preservedSidecars(previous).zip(preservedSidecars(database)).forEach { (kept, live) -> + if (kept.exists() && !kept.renameTo(live)) { + error("Unable to recover the previous Platoon database journal") + } + } + } + + /** Deletes a rollback copy, sidecars first; returns whether nothing of it remains. */ + private fun deletePreviousDatabase(previous: File): Boolean = + (databaseSidecars(previous).drop(1) + previous).all { file -> + !file.exists() || file.delete() + } } internal enum class RestoreCheckpoint { diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt index d449eea..a03fefb 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt @@ -11,12 +11,16 @@ import java.nio.charset.StandardCharsets import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption +import java.time.Duration import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter /** Validates and durably retains user-selected Platoon roster CSV evidence. */ -class PlatoonCsvImportStore(private val directory: File) { +class PlatoonCsvImportStore( + private val directory: File, + private val now: () -> Instant = Instant::now, +) { /** * Reads a bounded UTF-8 roster CSV, validates its complete schema, and hashes its bytes. Does * not create files, allowing callers to preview a whole selection before confirmation. Returns @@ -32,6 +36,9 @@ class PlatoonCsvImportStore(private val directory: File) { val parsed = requireNotNull(GuildMembersCsv.parse(text)) { "Invalid Platoon CSV" } require(parsed.members.isNotEmpty()) { "Platoon CSV contains no roster members" } val capturedAt = Instant.parse(parsed.logTime) + // A roster from the future would stay the "current" roster and hide every real capture + // after it; a mistyped year is the usual cause. + require(!capturedAt.isAfter(now().plus(MAX_CLOCK_SKEW))) { "Platoon CSV is dated in the future" } val hash = bytes.sha256().copyOf(HASH_BYTES).toHex() return PreparedImport( encoded = bytes, @@ -114,6 +121,9 @@ class PlatoonCsvImportStore(private val directory: File) { } companion object { + /** Allows a phone clock a little behind the PC that wrote the CSV. */ + private val MAX_CLOCK_SKEW: Duration = Duration.ofHours(1) + internal fun latestRetainedFile(directory: File): File? = directory.listFiles() .orEmpty() .filter { it.isFile && it.extension.equals("csv", ignoreCase = true) } 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 d15f281..3ad357d 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -63,11 +63,6 @@ internal class PlatoonDatabase private constructor( fun membershipPeriodOwner(membershipPeriodId: Long): Long? = PlatoonReadQueries.membershipPeriodOwner(readableDatabase, membershipPeriodId) - /** Period starts of manual weekly notes that cite one of [uid]'s membership events. */ - @Synchronized - fun weeklyNotePeriodsCitingMember(uid: Long): Set = - WeeklyAnnotationStore.notePeriodsCitingMember(readableDatabase, uid) - @Synchronized fun ingestSnapshot( snapshot: PlatoonSnapshot, 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 e245cc8..6abbf4e 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt @@ -150,6 +150,11 @@ enum class MetricCertainty { internal fun rangeCertainty(minimum: Number, maximum: Number): MetricCertainty = if (minimum.toLong() == maximum.toLong()) MetricCertainty.EXACT else MetricCertainty.LOWER_BOUND +/** + * A manual weekly note. [eventId] and [isAutomatic] describe retired automatic event notes, + * which the schema-6 upgrade deleted; they stay null and false but remain in the row and the + * weekly-history wire format so stored revisions and their fingerprints stay compatible. + */ data class WeeklyNote( val id: Long, val periodStart: LocalDate, diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt index fb1b1de..9c8273a 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt @@ -22,6 +22,16 @@ object PlatoonPeriods { fun gameDay(instant: Instant, zoneId: ZoneId): LocalDate = instant.atZone(zoneId).toLocalDateTime().minusHours(RESET_HOUR.toLong()).toLocalDate() + /** + * The game day whose counters a capture at [instant] records. A capture exactly at a 05:00 + * reset is the closing capture of the previous game day, since nothing of the new day can + * have been earned yet; every other capture records a prefix of its own [gameDay]. + */ + fun checkpointDay(instant: Instant, zoneId: ZoneId): LocalDate { + val day = gameDay(instant, zoneId) + return if (instant == periodStartInstant(day, zoneId)) day.minusDays(1) else day + } + fun weekStart(gameDay: LocalDate): LocalDate = gameDay.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) @@ -45,8 +55,9 @@ object PlatoonPeriods { LocalTime.of(GUNSMOKE_FINAL_SCORE_HOUR, 0), zoneId, ).toInstant() + // The Sunday 05:00 reset capture still closes the Saturday game day; see checkpointDay. return !capturedAt.isBefore(scoreClose) && - capturedAt.isBefore(periodStartInstant(gameDay.plusDays(1), zoneId)) + !capturedAt.isAfter(periodStartInstant(gameDay.plusDays(1), zoneId)) } private const val GUNSMOKE_CYCLE_WEEKS = 3L diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt index ecb9fb2..0ff9012 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt @@ -161,6 +161,14 @@ internal class PlatoonProfileRegistry(context: Context) { profiles.firstOrNull { it.hasIdentity(client, region, platoonId) } } + /** + * Whether selector metadata lists [storageId], without resuming pending deletions; the + * deletion queue itself asks this under the exclusive gate. + */ + fun isRegistered(storageId: String): Boolean = synchronized(lock) { + readLocked(storageId) != null + } + fun findByClientAndPlatoonId( client: PlatoonClient, platoonId: Long, @@ -510,14 +518,23 @@ internal class PlatoonProfileRegistry(context: Context) { platoonId: Long, ): String { val preferred = PlatoonProfileIdentity.storageId(client, region, platoonId) - if (readLocked(preferred) == null) return preferred + if (isAllocatableLocked(preferred)) return preferred repeat(32) { val random = PlatoonProfileIdentity.randomStorageId() - if (readLocked(random) == null) return random + if (isAllocatableLocked(random)) return random } error("Unable to allocate an isolated Platoon storage scope") } + /** + * A deleted profile's scope stays queued until its files are gone. Re-detecting the same + * Platoon meanwhile must not reuse that scope: the new profile would inherit what the failed + * deletion left behind, so it takes the random collision-safe fallback instead. + */ + private fun isAllocatableLocked(storageId: String): Boolean = + readLocked(storageId) == null && + !PlatoonProfileAdministration.isPendingDeletion(appContext, storageId) + private fun normalizeName(value: String): String = value .replace(WHITESPACE, " ") .filterNot(Char::isISOControl) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt index c610d5b..36789d2 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt @@ -85,11 +85,20 @@ internal class PlatoonProfileAdministration internal constructor( private const val PREFERENCES = "platoon_profile_deletions" private const val KEY_PENDING = "pending_storage_ids" + /** Finishes queued deletions off the main thread; the registry never lists their scopes. */ + private val cleanup = java.util.concurrent.Executors.newSingleThreadExecutor { Thread(it, "GF2ProfileCleanup") } + fun recoverPending(context: Context) { // A thread already reading a repository cannot take the exclusive gate; the // ReentrantReadWriteLock cannot upgrade. The next registry access finishes the work. if (PlatoonRepository.holdsSharedDatabaseAccess()) return val appContext = context.applicationContext + // Deleting a database waits for the exclusive gate behind capture and rebuilds, so a + // screen reading the registry hands the work to a background thread instead. + if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) { + if (pending(appContext).isNotEmpty()) cleanup.execute { recoverPending(appContext) } + return + } pending(appContext).forEach { storageId -> if (PlatoonProfileIdentity.isValidStorageId(storageId) && storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID @@ -101,10 +110,24 @@ internal class PlatoonProfileAdministration internal constructor( } } + /** Whether [storageId] still waits for deletion, so it must not be allocated again. */ + internal fun isPendingDeletion(context: Context, storageId: String): Boolean = + storageId in pending(context) + + /** + * Deletes one queued scope. A scope the registry lists again, as a committed restore of + * the deleted Platoon does, is live data: it leaves the queue instead of being deleted. + * The check and every deletion step run under the exclusive gate a restore also holds, + * and a rolled-back restore leaves the scope unlisted, so its deletion simply resumes. + */ private fun finishPending(context: Context, storageId: String): Boolean { val scope = PlatoonStorageScope(storageId) return runCatching { PlatoonRepository.withExclusiveDatabase(scope) { + if (PlatoonProfileRegistry(context).isRegistered(storageId)) { + dequeue(context, storageId) + return@withExclusiveDatabase false + } val database = context.getDatabasePath(scope.databaseName) if (database.exists()) { check(context.deleteDatabase(scope.databaseName)) { @@ -119,12 +142,12 @@ internal class PlatoonProfileAdministration internal constructor( if (root.exists()) { check(root.deleteRecursively()) { "Unable to delete Platoon evidence" } } + MemberOrderPreferences(context, storageId).clear() + WeeklyCutlinePreferences(context, storageId).clear() + GameTimeZonePreferences.clearScope(context, storageId) + dequeue(context, storageId) + true } - MemberOrderPreferences(context, storageId).clear() - WeeklyCutlinePreferences(context, storageId).clear() - GameTimeZonePreferences.clearScope(context, storageId) - dequeue(context, storageId) - true }.getOrDefault(false) } 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 108258e..2da6214 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -142,9 +142,12 @@ internal class PlatoonRepository( .sortedWith(compareBy(RetainedCsv::capturedAt, { it.file.name })) .forEach { file -> val historicalOnly = latestStructuredSnapshot?.let { - file.capturedAt.isBefore(it.capturedAt) || - (file.capturedAt == it.capturedAt && - file.file.name <= it.sourceFile.orEmpty()) + SnapshotReconciler.isHistorical( + file.capturedAt, + file.file.name, + it.capturedAt, + it.sourceFile, + ) } ?: false val result = database.ingestSnapshot( PlatoonSnapshot( @@ -208,9 +211,14 @@ internal class PlatoonRepository( access { it.listDailyPatrolFacts(from, until) } fun updateMember(uid: Long, name: String, note: String): Boolean = - refreshingWeeksOf(uid) { access { it.updateMember(uid, name, note) } } + refreshingWeeksOf(uid, WeeklyHistoryWorkPolicy.MemberEdit.DISPLAY_ONLY) { + access { it.updateMember(uid, name, note) } + } - fun deleteMember(uid: Long): Boolean = refreshingWeeksOf(uid) { + fun deleteMember(uid: Long): Boolean = refreshingWeeksOf( + uid, + WeeklyHistoryWorkPolicy.MemberEdit.REMOVAL, + ) { withExclusiveDatabase(storageScope) { val order = MemberOrderPreferences(appContext, storageScope.storageId) val previousOrder = order.read() @@ -365,7 +373,6 @@ internal class PlatoonRepository( report = report, membershipEvents = membershipEvents, notes = inputs.notes - .filterNot(WeeklyNote::isAutomatic) .take(MAX_REVISION_NOTES) .map { it.copy(text = it.text.boundedUtf8(MAX_REVISION_NOTE_BYTES)) }, memberNamesByUid = inputs.statuses.asSequence() @@ -439,6 +446,10 @@ internal class PlatoonRepository( .periodStarts(access { it.listWeeklyEvidenceDays(zoneId) }) .map { buildWeeklyReport(it, zoneId, asOf) } + /** Whether any week has evidence, without building a single report. */ + fun hasWeeklyReports(zoneId: ZoneId): Boolean = + WeeklyReportRange.periodStarts(access { it.listWeeklyEvidenceDays(zoneId) }).isNotEmpty() + fun replaceWeeklyOverrides( periodStartEpochDay: Long, overrides: List, @@ -521,25 +532,30 @@ internal class PlatoonRepository( /** * Runs a mutation confined to one member's rows and refreshes only the weeks whose revision * can show that member before or after it: weeks whose period-end roster or Join / Withdraw - * section contains the member, or whose notes cite the member's events. Every other row, - * name, and note of a revision depends on other members' evidence alone. If the pre-scan - * fails, every week is refreshed so the mutation stays authoritative. + * section contains the member. Weekly notes are manual and cite no member events, so every + * other row, name, and note of a revision depends on other members' evidence alone. If the + * pre-scan fails, every week is refreshed so the mutation stays authoritative. */ - private fun refreshingWeeksOf(uid: Long?, mutation: () -> Boolean): Boolean { + private fun refreshingWeeksOf( + uid: Long?, + edit: WeeklyHistoryWorkPolicy.MemberEdit = WeeklyHistoryWorkPolicy.MemberEdit.MEMBERSHIP, + mutation: () -> Boolean, + ): Boolean { val zone = gameZone() val asOf = Instant.now() - val before = uid?.let { runCatching { weeksShowingMember(it, zone, asOf) }.getOrNull() } + val before = uid?.let { + runCatching { weeksShowingMember(it, zone, asOf, weeklyPeriodStarts(zone)) }.getOrNull() + } if (!mutation()) return false try { - val weeks = weeklyPeriodStarts(zone) - val affected = if (uid == null || before == null) { - weeks - } else { - val current = weeks.toSet() - (before.filter { it in current } + weeksShowingMember(uid, zone, asOf)).distinct() - .sorted() - } - affected.forEach { recordLiveWeeklyRevisionSafely(it, zone) } + WeeklyHistoryWorkPolicy.memberEditPeriodStarts( + edit = edit, + showingBefore = before, + listedAfter = { weeklyPeriodStarts(zone) }, + showingAfter = { listed -> + weeksShowingMember(requireNotNull(uid), zone, asOf, listed) + }, + ).forEach { recordLiveWeeklyRevisionSafely(it, zone) } } catch (error: Exception) { Log.e(TAG, "Unable to scope weekly history refresh; refreshing every week", error) recordAllLiveWeeklyReports() @@ -552,12 +568,17 @@ internal class PlatoonRepository( mutation: () -> Boolean, ): Boolean = refreshingWeeksOf( runCatching { access { it.membershipPeriodOwner(membershipPeriodId) } }.getOrNull(), - mutation, + mutation = mutation, ) - private fun weeksShowingMember(uid: Long, zone: ZoneId, asOf: Instant): Set { - val citingNotes = access { it.weeklyNotePeriodsCitingMember(uid) } - return weeklyPeriodStarts(zone).filterTo(citingNotes.toMutableSet()) { periodStart -> + /** The [listed] weeks whose revision shows [uid]; each loads one week's roster inputs. */ + private fun weeksShowingMember( + uid: Long, + zone: ZoneId, + asOf: Instant, + listed: List, + ): Set = + listed.filterTo(mutableSetOf()) { periodStart -> val inputs = access { database -> database.readConsistently { readRosterInputs(database, periodStart, zone, asOf) } } @@ -570,7 +591,6 @@ internal class PlatoonRepository( asOf, ) } - } private fun recordHistory( revision: WeeklyTableRevision, diff --git a/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt b/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt index 8087614..977f036 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt @@ -1,5 +1,10 @@ package dev.gf2log.app.management +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + object SnapshotComparison { data class MeritChange( val uid: Long, @@ -8,6 +13,8 @@ object SnapshotComparison { val totalMeritDelta: Long, val totalScoreDelta: Long, val lastLoginChanged: Boolean, + val weeklyMeritDeltaCertainty: MetricCertainty = MetricCertainty.EXACT, + val totalScoreDeltaCertainty: MetricCertainty = MetricCertainty.EXACT, ) data class Result( @@ -18,10 +25,21 @@ object SnapshotComparison { val changes: List, ) - fun compare(older: PlatoonSnapshot, newer: PlatoonSnapshot): Result { + /** + * Compares two rosters captured in the Platoon's [zoneId]. Merit This Week restarts at every + * Monday 05:00 and the Gunsmoke score at every event start, so a delta across either reset is + * only the newer counter's lower bound: the older counter's gain before the reset is unknown. + */ + fun compare(older: PlatoonSnapshot, newer: PlatoonSnapshot, zoneId: ZoneId): Result { require(!newer.capturedAt.isBefore(older.capturedAt)) val olderByUid = older.members.associateBy(SnapshotMember::uid) val newerByUid = newer.members.associateBy(SnapshotMember::uid) + val weeklyReset = resetBetween(older.capturedAt, newer.capturedAt, zoneId) { day -> + day.dayOfWeek == DayOfWeek.MONDAY + } + val eventReset = resetBetween(older.capturedAt, newer.capturedAt, zoneId) { day -> + day.dayOfWeek == DayOfWeek.SUNDAY && PlatoonPeriods.isGunsmokeWeek(day) + } return Result( older = older, newer = newer, @@ -29,13 +47,19 @@ object SnapshotComparison { left = olderByUid.filterKeys { it !in newerByUid }.values.sortedBy { it.name }, changes = newerByUid.values.mapNotNull { current -> val previous = olderByUid[current.uid] ?: return@mapNotNull null + val (weeklyDelta, weeklyCertainty) = + resettingDelta(previous.weeklyMerit, current.weeklyMerit, weeklyReset) + val (scoreDelta, scoreCertainty) = + resettingDelta(previous.totalScore, current.totalScore, eventReset) MeritChange( uid = current.uid, name = current.name, - weeklyMeritDelta = ActivityInference.counterDelta(previous.weeklyMerit, current.weeklyMerit), + weeklyMeritDelta = weeklyDelta, totalMeritDelta = ActivityInference.counterDelta(previous.totalMerit, current.totalMerit), - totalScoreDelta = ActivityInference.counterDelta(previous.totalScore, current.totalScore), + totalScoreDelta = scoreDelta, lastLoginChanged = current.lastLogin != previous.lastLogin, + weeklyMeritDeltaCertainty = weeklyCertainty, + totalScoreDeltaCertainty = scoreCertainty, ) }.filter { it.weeklyMeritDelta != 0L || @@ -45,4 +69,37 @@ object SnapshotComparison { }.sortedByDescending(MeritChange::weeklyMeritDelta), ) } + + /** A reset between the captures, or an unexplained drop, leaves only the newer counter. */ + private fun resettingDelta( + previous: Long, + current: Long, + resetBetween: Boolean, + ): Pair = if (resetBetween || current < previous) { + current to MetricCertainty.LOWER_BOUND + } else { + current - previous to MetricCertainty.EXACT + } + + /** + * Whether a 05:00 reset of a game day matching [isReset] falls within [from]..[to]. A reset + * at either capture counts, since a capture at the reset instant may precede the restart. + */ + private fun resetBetween( + from: Instant, + to: Instant, + zoneId: ZoneId, + isReset: (LocalDate) -> Boolean, + ): Boolean { + val last = PlatoonPeriods.gameDay(to, zoneId) + var day = PlatoonPeriods.gameDay(from, zoneId) + while (!day.isAfter(last)) { + if (isReset(day)) { + val resetAt = PlatoonPeriods.periodStartInstant(day, zoneId) + if (!resetAt.isBefore(from) && !resetAt.isAfter(to)) return true + } + day = day.plusDays(1) + } + return false + } } diff --git a/app/src/main/java/dev/gf2log/app/management/SnapshotComparisonCsv.kt b/app/src/main/java/dev/gf2log/app/management/SnapshotComparisonCsv.kt index c1b8362..a061210 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotComparisonCsv.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotComparisonCsv.kt @@ -5,15 +5,16 @@ import dev.gf2log.protocol.CsvCell object SnapshotComparisonCsv { const val HEADER = "olderCapturedAt,newerCapturedAt,changeType,uid,name,weeklyMeritDelta," + - "totalMeritDelta,totalScoreDelta,lastLoginChanged" + "totalMeritDelta,totalScoreDelta,lastLoginChanged,weeklyMeritDeltaCertainty," + + "totalScoreDeltaCertainty" fun format(result: SnapshotComparison.Result): String = buildString { appendLine(HEADER) result.joined.forEach { member -> - appendRow(result, "JOINED", member.uid, member.name, null, null, null, null) + appendRow(result, "JOINED", member.uid, member.name, null, null, null, null, null, null) } result.left.forEach { member -> - appendRow(result, "WITHDREW", member.uid, member.name, null, null, null, null) + appendRow(result, "WITHDREW", member.uid, member.name, null, null, null, null, null, null) } result.changes.forEach { change -> appendRow( @@ -25,6 +26,8 @@ object SnapshotComparisonCsv { change.totalMeritDelta, change.totalScoreDelta, change.lastLoginChanged, + change.weeklyMeritDeltaCertainty, + change.totalScoreDeltaCertainty, ) } }.trimEnd() + "\n" @@ -38,6 +41,8 @@ object SnapshotComparisonCsv { totalMerit: Long?, totalScore: Long?, lastLoginChanged: Boolean?, + weeklyMeritCertainty: MetricCertainty?, + totalScoreCertainty: MetricCertainty?, ) { appendLine( listOf( @@ -50,6 +55,8 @@ object SnapshotComparisonCsv { totalMerit, totalScore, lastLoginChanged, + weeklyMeritCertainty, + totalScoreCertainty, ).joinToString(",") { CsvCell.escape(it) }, ) } diff --git a/app/src/main/java/dev/gf2log/app/management/SnapshotReconciler.kt b/app/src/main/java/dev/gf2log/app/management/SnapshotReconciler.kt index db2b30b..d8479be 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotReconciler.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotReconciler.kt @@ -1,5 +1,7 @@ package dev.gf2log.app.management +import java.time.Instant + object SnapshotReconciler { data class KnownMember( val uid: Long, @@ -22,6 +24,19 @@ object SnapshotReconciler { val newName: String, ) + /** + * Whether a roster file captured at [capturedAt] as [sourceFile] sorts at or before the + * newest stored roster, so it must be replayed as history instead of becoming the current + * roster. Equal capture instants are ordered by source file name. + */ + fun isHistorical( + capturedAt: Instant, + sourceFile: String, + latestCapturedAt: Instant, + latestSourceFile: String?, + ): Boolean = capturedAt.isBefore(latestCapturedAt) || + (capturedAt == latestCapturedAt && sourceFile <= latestSourceFile.orEmpty()) + fun reconcile( known: Collection, incoming: Collection, diff --git a/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt b/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt index 93622d5..97fde3d 100644 --- a/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt @@ -32,9 +32,14 @@ object StandardWeekSolver { ).toMutableList() val observations = snapshots.mapNotNull { snapshot -> snapshot.member(uid)?.let { member -> + // A capture at a 05:00 reset is the complete previous day, so the Sunday 05:00 + // capture closes Saturday instead of falling outside the counter week. + val gameDay = PlatoonPeriods.checkpointDay(snapshot.capturedAt, zoneId) CounterObservation( capturedAt = snapshot.capturedAt, - gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId), + gameDay = gameDay, + closing = snapshot.capturedAt == + PlatoonPeriods.periodStartInstant(gameDay.plusDays(1), zoneId), counter = member.weeklyMerit, lastLoginDay = member.lastLogin .takeIf { it > 0L } @@ -49,14 +54,15 @@ object StandardWeekSolver { ?: return Resolution(mutable, null) val latestDay = latest.gameDay val firstObserved = observationsInCounterWeek.minBy(CounterObservation::capturedAt) - val knownAbsentBeforeFirstObservation = snapshots.any { snapshot -> - val gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId) - !gameDay.isBefore(monday) && - gameDay.isBefore(firstObserved.gameDay) && - snapshot.capturedAt.isBefore(firstObserved.capturedAt) && - snapshot.member(uid) == null + // A member missing from a roster can join right after it, so the counter can already + // hold merit from the rest of that game day; only earlier days are known to be empty. + val latestAbsence = snapshots.lastOrNull { snapshot -> + snapshot.capturedAt.isBefore(firstObserved.capturedAt) && snapshot.member(uid) == null } - val activeStart = if (knownAbsentBeforeFirstObservation) firstObserved.gameDay else monday + val activeStart = latestAbsence + ?.let { PlatoonPeriods.gameDay(it.capturedAt, zoneId) } + ?.takeIf { it.isAfter(monday) } + ?: monday val activeIndexes = days.indices.filter { index -> days[index] in activeStart..latestDay } if (activeIndexes.isEmpty()) return Resolution(mutable, null) @@ -323,6 +329,7 @@ object StandardWeekSolver { val stage = checkpoint.counter - completedPrefix val finalForDay = allocation[position] if (stage !in DAILY_STAGE || stage > finalForDay) return null + if (checkpoint.closing && stage != finalForDay) return null val loginStageCompatible = when { checkpoint.lastLoginDay == checkpoint.gameDay -> stage == LOGIN_MERIT || stage == PATROL_MERIT @@ -336,6 +343,8 @@ object StandardWeekSolver { private data class CounterObservation( val capturedAt: Instant, val gameDay: LocalDate, + /** Captured exactly at the reset that ends [gameDay], so its stage is the final one. */ + val closing: Boolean, val counter: Long, val lastLoginDay: LocalDate?, ) diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt index 215793c..d29d3a5 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt @@ -74,14 +74,6 @@ internal object WeeklyAnnotationStore { if (cursor.moveToFirst()) LocalDate.ofEpochDay(cursor.getLong(0)) else null } - fun notePeriodsCitingMember(db: SQLiteDatabase, uid: Long): Set = db.rawQuery( - "SELECT DISTINCT period_start FROM weekly_notes WHERE is_automatic = 0 AND event_id IN " + - "(SELECT id FROM member_events WHERE uid = ?)", - arrayOf(uid.toString()), - ).use { cursor -> - buildSet { while (cursor.moveToNext()) add(LocalDate.ofEpochDay(cursor.getLong(0))) } - } - fun deleteWeeklyNote(db: SQLiteDatabase, id: Long): Boolean = db.delete( "weekly_notes", diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt index c2cc65f..a56cf48 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt @@ -19,6 +19,8 @@ internal object WeeklyHistoryStore { * Appends one changed immutable weekly projection and retains the newest * [MAX_WEEKLY_REPORT_HISTORY] revisions. A restored selection is cleared only when * [clearActiveOnChange] and the live fingerprint changed. Returns the existing or new ID. + * Recency is the AUTOINCREMENT ID, never the wall-clock [recordedAt]: after the device clock + * steps backwards a new revision must still be the latest rather than be trimmed at once. */ fun record( db: SQLiteDatabase, @@ -32,7 +34,7 @@ internal object WeeklyHistoryStore { return db.inTransaction { val latest = db.rawQuery( "SELECT id, fingerprint FROM weekly_report_history " + - "WHERE period_start = ? ORDER BY recorded_at DESC, id DESC LIMIT 1", + "WHERE period_start = ? ORDER BY id DESC LIMIT 1", arrayOf(periodStartEpochDay.toString()), ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) to cursor.getString(1) else null @@ -54,7 +56,7 @@ internal object WeeklyHistoryStore { "weekly_report_history", "period_start = ? AND id NOT IN (" + "SELECT id FROM weekly_report_history WHERE period_start = ? " + - "ORDER BY recorded_at DESC, id DESC LIMIT ?)", + "ORDER BY id DESC LIMIT ?)", arrayOf( periodStartEpochDay.toString(), periodStartEpochDay.toString(), @@ -73,7 +75,7 @@ internal object WeeklyHistoryStore { "LEFT JOIN weekly_report_history_state state " + "ON state.period_start = history.period_start " + "WHERE history.period_start = ? " + - "ORDER BY history.recorded_at DESC, history.id DESC LIMIT ?", + "ORDER BY history.id DESC LIMIT ?", arrayOf(periodStartEpochDay.toString(), MAX_WEEKLY_REPORT_HISTORY.toString()), ).use { cursor -> buildList { diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt index e779cce..1951bcb 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt @@ -52,5 +52,39 @@ internal object WeeklyHistoryWorkPolicy { .take(MAX_CHANGED_WEEKS_PER_INGEST) } + /** What a member edit can change about the weeks that list or show that member. */ + enum class MemberEdit { + /** Rewrites membership periods or boundaries: listed weeks and rosters can change. */ + MEMBERSHIP, + + /** Changes only the custom name or private note: weeks and rosters stay as they were. */ + DISPLAY_ONLY, + + /** Deletes every row of the member: afterwards no week shows the member. */ + REMOVAL, + } + + /** + * The weeks one member edit refreshes: the weeks that showed the member before it and are + * still listed, then the weeks that show the member after it, in week order. A null + * [showingBefore] means the pre-scan failed, so every listed week is refreshed. The + * post-edit listing and scan are evaluated only when [edit] can change them: a + * [MemberEdit.DISPLAY_ONLY] edit reuses the pre-scan, because its listing and rosters are + * those it already read, and after [MemberEdit.REMOVAL] the member is shown nowhere. + */ + fun memberEditPeriodStarts( + edit: MemberEdit, + showingBefore: Set?, + listedAfter: () -> List, + showingAfter: (listed: List) -> Set, + ): List { + if (showingBefore == null) return listedAfter() + if (edit == MemberEdit.DISPLAY_ONLY) return showingBefore.sorted() + val listed = listedAfter() + val current = listed.toSet() + val after = if (edit == MemberEdit.REMOVAL) emptySet() else showingAfter(listed) + return (showingBefore.filter { it in current } + after).distinct().sorted() + } + private const val DAYS_PER_WEEK = 7L } diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt index dfd7859..4583128 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt @@ -459,7 +459,7 @@ object WeeklyReportBuilder { } val cells = gunsmokeResolution?.cells ?: standardResolution?.cells ?: derivedCells val snapshotsInPeriod = week.snapshots.mapNotNull { snapshot -> - val gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, week.zoneId) + val gameDay = PlatoonPeriods.checkpointDay(snapshot.capturedAt, week.zoneId) snapshot.member(uid) ?.takeIf { gameDay in week.days } ?.let { gameDay to it } @@ -633,7 +633,30 @@ object WeeklyReportBuilder { scoreDelta = if (isGunsmoke) null else 0L } else { val lastKnown = after ?: before - meritDelta = ActivityInference.counterDelta(before.totalMerit, lastKnown.totalMerit) + val intervalMerit = ActivityInference.counterDelta(before.totalMerit, lastKnown.totalMerit) + meritDelta = if ( + !isGunsmoke && + baselineSnapshot.capturedAt != start && + intervalMerit > STANDARD_PATROL_MERIT + ) { + // A near-reset baseline can leave the previous day's login or patrol in the + // delta. Above the daily cap that tail is certain, so the day is kept only when + // exactly one split remains; it is never shown above the cap. + standardPrefixesAfterTail(intervalMerit).singleOrNull() + ?: return withActivityFacts( + DayCell( + gameDay = day, + meritDelta = null, + scoreDelta = null, + inference = null, + evidence = DailyEvidence.INCOMPLETE_BOUNDARY, + metricObservedAt = lastObservation?.first?.capturedAt, + hasClosingBoundary = hasClosingBoundary, + ), + ) + } else { + intervalMerit + } scoreDelta = if (isGunsmoke) { ActivityInference.counterDelta(before.totalScore, lastKnown.totalScore) } else { @@ -649,7 +672,12 @@ object WeeklyReportBuilder { // A capture immediately before 05:00 is still not a final // boundary: merit can change before reset. Exactness is // established later by the counter constraint solver. - evidence = DailyEvidence.PARTIAL_DAY, + evidence = if (!isGunsmoke && before != null && meritDelta == STANDARD_PATROL_MERIT) { + // The 90-point cap completes a Standard day before its reset. + DailyEvidence.ATTRIBUTED + } else { + DailyEvidence.PARTIAL_DAY + }, hasFinalGunsmokeScore = hasFinalGunsmokeScore, isGunsmokeWeek = isGunsmoke, metricObservedAt = lastObservation?.first?.capturedAt, @@ -688,6 +716,8 @@ object WeeklyReportBuilder { if (!hasDailyPatrolFact && !hasLoginFact) return cell val minimumMerit = if (hasDailyPatrolFact) STANDARD_PATROL_MERIT else STANDARD_LOGIN_MERIT val merit = when { + // Daily Patrol completes a Standard day at its cap; a larger delta is contaminated. + !isGunsmoke && hasDailyPatrolFact -> STANDARD_PATROL_MERIT cell.meritDelta == null -> minimumMerit else -> maxOf(cell.meritDelta, minimumMerit) } @@ -708,6 +738,13 @@ object WeeklyReportBuilder { ) } + /** + * The Standard-day prefixes that leave a valid previous-day tail in [delta]. A tail can only + * add the login, the Daily Patrol, or both to a day that had not finished them yet. + */ + private fun standardPrefixesAfterTail(delta: Long): List = + STANDARD_STAGES.filter { prefix -> delta - prefix in STANDARD_TAILS } + private fun List.lastBefore(instant: Instant): PlatoonSnapshot? = lastOrNull { it.capturedAt.isBefore(instant) } @@ -761,4 +798,11 @@ object WeeklyReportBuilder { private const val MAX_STANDARD_WEEKLY_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT * 7 private const val STANDARD_LOGIN_MERIT = ActivityInference.LOGIN_MERIT private const val STANDARD_PATROL_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT + private val STANDARD_STAGES = listOf(0L, STANDARD_LOGIN_MERIT, STANDARD_PATROL_MERIT) + private val STANDARD_TAILS = setOf( + 0L, + ActivityInference.DAILY_PATROL_MERIT, + STANDARD_LOGIN_MERIT, + STANDARD_PATROL_MERIT, + ) } diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyReportStateHolder.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyReportStateHolder.kt index baf5a2e..a54bd50 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportStateHolder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportStateHolder.kt @@ -3,39 +3,13 @@ package dev.gf2log.app.management import java.time.LocalDate /** - * Owns weekly-screen navigation and asynchronous render validity independently - * from Android views. A monotonically increasing generation makes a result - * applicable only to the request that is still current while the screen is - * resumed. + * Owns weekly-screen navigation independently from Android views. Stale renders need no guard + * here: the screen's background work replaces an older render with the newest one. */ class WeeklyReportStateHolder(initialReferenceDay: LocalDate) { var referenceDay: LocalDate = initialReferenceDay private set - private var resumed = false - private var generation = 0 - - data class RenderRequest( - val referenceDay: LocalDate, - val generation: Int, - ) - - fun onResume() { - resumed = true - } - - fun onPause() { - resumed = false - generation += 1 - } - - fun newRenderRequest(): RenderRequest = RenderRequest( - referenceDay = referenceDay, - generation = ++generation, - ) - - fun canApply(generation: Int): Boolean = resumed && generation == this.generation - fun showPreviousWeek(periodStart: LocalDate) { referenceDay = periodStart.minusDays(1) } diff --git a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt index 30862a7..ab97b2e 100644 --- a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt @@ -9,7 +9,7 @@ import java.time.ZoneId internal object GameTimeZonePreferences { fun get( context: Context, - storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, + storageId: String, ): ZoneId { if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) return legacyZone(context) val region = region(context, storageId) @@ -22,7 +22,7 @@ internal object GameTimeZonePreferences { fun region( context: Context, - storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, + storageId: String, ): GameServerRegion { if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) return legacyRegion(context) val stored = scoped(context).getString(regionKey(storageId), null) @@ -72,7 +72,7 @@ internal object GameTimeZonePreferences { fun setRegion( context: Context, region: GameServerRegion, - storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, + storageId: String, ) { if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) { UserSettingsPreferences.setGameServerRegion(context, region.storedValue) @@ -84,7 +84,7 @@ internal object GameTimeZonePreferences { fun set( context: Context, zoneId: ZoneId, - storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, + storageId: String, ) { if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) { UserSettingsPreferences.setGameTimeZoneId(context, zoneId.id) diff --git a/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt index f30aaf1..a0091f1 100644 --- a/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt @@ -2,11 +2,10 @@ package dev.gf2log.app.settings import android.content.Context import dev.gf2log.app.management.PlatoonProfileIdentity -import dev.gf2log.app.management.PlatoonProfileRegistry class MemberOrderPreferences( context: Context, - private val storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, + private val storageId: String, ) { private val appContext = context.applicationContext private val scoped = appContext.getSharedPreferences(PROFILE_PREFERENCES, Context.MODE_PRIVATE) diff --git a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt index 6905863..223f9bf 100644 --- a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt @@ -2,7 +2,6 @@ package dev.gf2log.app.settings import android.content.Context import dev.gf2log.app.management.PlatoonProfileIdentity -import dev.gf2log.app.management.PlatoonProfileRegistry data class WeeklyCutlines( val dailyMerit: Long? = null, @@ -41,7 +40,7 @@ data class WeeklyCutlines( class WeeklyCutlinePreferences( context: Context, - private val storageId: String = PlatoonProfileRegistry(context).activeScope().storageId, + private val storageId: String, ) { private val appContext = context.applicationContext private val scoped = appContext.getSharedPreferences(PROFILE_PREFERENCES, Context.MODE_PRIVATE) diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt index 956f06b..f6050e3 100644 --- a/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt +++ b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt @@ -60,7 +60,11 @@ internal object StandbyAutomation { private var tapsUntil = 0L private var loggedIn = false private var foreignSince = 0L + private var clearingStaleGame = false + private var gameShown = false private var startedCapture = false + /** [CaptureSnapshot.runCount] when this automation last sent a stop the service has not seen yet. */ + private var stopSentAtRun: Long? = null private var previousActive: String? = null private var handingOff = false private var lockWhenDone = true @@ -101,6 +105,7 @@ internal object StandbyAutomation { previousActive = null handingOff = false openedGame = null + clearingStaleGame = false this.lockWhenDone = lockWhenDone enter(Phase.WAITING) appContext.registerReceiver(screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF)) @@ -143,14 +148,19 @@ internal object StandbyAutomation { Phase.IDLE -> Unit Phase.WAITING -> if (now >= runs[index].startAt.toEpochMilli()) beginRun(now) Phase.STARTING_CAPTURE -> when { - CaptureStatus.snapshot().phase == CapturePhase.RUNNING -> launchGame(now) + // Clearing a leftover game from Recents; launchGame follows when it is done. + clearingStaleGame -> Unit + // A capture the previous run stopped may still read RUNNING until the stop lands. + CaptureStatus.snapshot().let { it.phase == CapturePhase.RUNNING && !ownStopPending(it) } -> + clearStaleGame() now - phaseStartedAt >= CAPTURE_START_TIMEOUT_MS -> endRun(StandbyOutcome.CAPTURE_FAILED) } Phase.IN_GAME -> when { // Someone switched to another app or Home: they are awake, so stand down // without closing anything or locking the phone in their hand. userLeftGame(now) -> cancel() - CaptureStatus.snapshot().phase == CapturePhase.STOPPED -> endRun(StandbyOutcome.CAPTURE_FAILED) + CaptureStatus.snapshot().phase.let { it == CapturePhase.STOPPING || it == CapturePhase.STOPPED } -> + endRun(StandbyOutcome.CAPTURE_FAILED) now >= runTimeoutAt -> endRun(StandbyOutcome.TIMED_OUT) // Taps only reach the title screen: they stop at login, and in any case after the // window, so nothing inside the game is ever pressed. @@ -168,7 +178,11 @@ internal object StandbyAutomation { /** True once something other than the game, or this app, has been in front for a while. */ private fun userLeftGame(now: Long): Boolean { val front = StandbyAutomationService.instance?.frontPackage() - if (front == null || front == runs[index].server.packageName || front == appContext.packageName) { + val game = runs[index].server.packageName + if (front == game) gameShown = true + // Until the game is first in front (a cold start from Recents shows the launcher for a + // while) nobody can have left it; a launch that never shows ends by the run's timeout. + if (!gameShown || front == null || front == game || front == appContext.packageName) { foreignSince = 0L return false } @@ -177,6 +191,7 @@ internal object StandbyAutomation { return now - foreignSince >= USER_SWITCH_MS } + private fun beginRun(now: Long) { val run = runs[index] if (now >= run.deadline.toEpochMilli() - MIN_RUN_MS) return skip(StandbyOutcome.MISSED) @@ -216,7 +231,11 @@ internal object StandbyAutomation { } private fun startCapture() { - if (CaptureStatus.snapshot().phase != CapturePhase.STOPPED) return + val capture = CaptureStatus.snapshot() + // A capture someone else started is reused as it is. One that is stopping, or that the + // previous run just asked to stop, restarts once its teardown ends, then reaches RUNNING. + val active = capture.phase == CapturePhase.STARTING || capture.phase == CapturePhase.RUNNING + if (active && !ownStopPending(capture)) return val started = runCatching { appContext.startForegroundService( Intent(appContext, CaptureVpnService::class.java) @@ -232,6 +251,41 @@ internal object StandbyAutomation { } } + /** + * True while the stop [releaseRun] sent has not reached the service: the run it stopped still + * reads as active. Clears itself once the service is seen stopping, stopped, or on a newer run. + */ + private fun ownStopPending(capture: CaptureSnapshot): Boolean { + val stoppedRun = stopSentAtRun ?: return false + val active = capture.phase == CapturePhase.STARTING || capture.phase == CapturePhase.RUNNING + if (active && capture.runCount == stoppedRun) return true + stopSentAtRun = null + return false + } + + /** + * A game left in the background from earlier (an evening session, say) can come back to a + * "reconnection failed" notice that no tap here may press, so its Recents card is closed first + * and every run starts the game fresh. Recents takes over the screen: the standby screen + * stops and closes itself without cancelling, and the screen-on hold of this run stays. + */ + private fun clearStaleGame() { + val run = runs[index] + val service = StandbyAutomationService.instance ?: return launchGame(System.currentTimeMillis()) + clearingStaleGame = true + if (standby.get()?.takeUnless { it.isFinishing } != null) handingOff = true + service.closeFromRecents(run.server.packageName) { closed -> + if (closed) Log.i(TAG, "Closed a leftover ${run.server.packageName} before launching it") + // Android kills a dismissed task's process a moment later; a launch before that lands + // in the dying process and vanishes with it. + handler.postDelayed({ + clearingStaleGame = false + if (phase != Phase.STARTING_CAPTURE || runs.getOrNull(index) !== run) return@postDelayed + launchGame(System.currentTimeMillis()) + }, if (closed) STALE_GAME_SETTLE_MS else 0L) + } + } + private fun launchGame(now: Long) { val run = runs[index] val launch = appContext.packageManager.getLaunchIntentForPackage(run.server.packageName) @@ -253,11 +307,13 @@ internal object StandbyAutomation { tapsUntil = now + TAP_WINDOW_MS loggedIn = false foreignSince = 0L + gameShown = false enter(Phase.IN_GAME) } /** Platoon notices only follow a login, so the title screen is gone once one arrives. */ private fun onCaptureChanged(snapshot: CaptureSnapshot) { + ownStopPending(snapshot) val notice = snapshot.notice ?: return if (phase == Phase.IN_GAME && notice.notice in LOGIN_NOTICES && notice.atMillis >= phaseStartedAt) { loggedIn = true @@ -367,6 +423,7 @@ internal object StandbyAutomation { CaptureStatus.removeListener(captureListener) runCatching { appContext.unregisterReceiver(screenOffReceiver) } handingOff = false + clearingStaleGame = false StandbyResults.save(appContext, results.toList()) } @@ -374,11 +431,12 @@ internal object StandbyAutomation { private fun releaseRun() { if (startedCapture) { startedCapture = false + val runCount = CaptureStatus.snapshot().runCount runCatching { appContext.startService( Intent(appContext, CaptureVpnService::class.java).setAction(CaptureVpnService.ACTION_STOP), ) - } + }.onSuccess { stopSentAtRun = runCount } } previousActive?.let { previous -> previousActive = null @@ -418,6 +476,7 @@ internal object StandbyAutomation { private const val TAP_INTERVAL_MS = 4_000L private const val TAP_WINDOW_MS = 2 * 60_000L private const val USER_SWITCH_MS = 5_000L + private const val STALE_GAME_SETTLE_MS = 2_500L private const val RUN_TIMEOUT_MS = 4 * 60_000L private const val MIN_RUN_MS = 60_000L private const val RETURN_TIMEOUT_MS = 8_000L diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyAutomationService.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomationService.kt index efff784..09dcdf5 100644 --- a/app/src/main/java/dev/gf2log/app/standby/StandbyAutomationService.kt +++ b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomationService.kt @@ -87,7 +87,18 @@ class StandbyAutomationService : AccessibilityService() { packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString() }.getOrNull() if (label.isNullOrBlank() || !performGlobalAction(GLOBAL_ACTION_RECENTS)) return done(false) - handler.postDelayed({ done(dismissCard(label)) }, RECENTS_SETTLE_MS) + tryDismiss(label, RECENTS_ATTEMPTS, done) + } + + /** Recents animates in at its own pace, slower on some phones; look again before giving up. */ + private fun tryDismiss(label: String, attemptsLeft: Int, done: (Boolean) -> Unit) { + handler.postDelayed({ + when { + dismissCard(label) -> done(true) + attemptsLeft > 1 -> tryDismiss(label, attemptsLeft - 1, done) + else -> done(false) + } + }, RECENTS_SETTLE_MS) } private fun dismissCard(label: String): Boolean { @@ -146,7 +157,8 @@ class StandbyAutomationService : AccessibilityService() { companion object { private const val TAP_DURATION_MS = 60L - private const val RECENTS_SETTLE_MS = 1_200L + private const val RECENTS_SETTLE_MS = 800L + private const val RECENTS_ATTEMPTS = 4 @Volatile private var current = java.lang.ref.WeakReference(null) diff --git a/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt b/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt index 94e7ec5..04a4664 100644 --- a/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt +++ b/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt @@ -3,13 +3,15 @@ package dev.gf2log.app.ui import android.app.Activity import android.content.Context import android.content.Intent +import android.os.Handler +import android.os.Looper import androidx.annotation.StringRes import dev.gf2log.app.R import dev.gf2log.app.capture.CapturePhase import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.capture.CaptureVpnService -/** True from the moment capture is requested until it stops; risky data operations wait. */ +/** True from the moment capture is requested until its teardown finishes; risky data operations wait. */ internal val captureActive: Boolean get() = CaptureStatus.snapshot().phase != CapturePhase.STOPPED @@ -18,20 +20,54 @@ internal val captureActive: Boolean * sending the user back to Home to find the Stop button. */ internal object CaptureGuard { + /** Teardown joins the native loop and drains queued chunks; anything longer means it is stuck. */ + private const val STOP_TIMEOUT_MS = 20_000L + + private val handler by lazy { Handler(Looper.getMainLooper()) } + + /** Requests a stop; the service publishes STOPPED only after its teardown has finished. */ fun stop(context: Context) { context.startService(Intent(context, CaptureVpnService::class.java).setAction(CaptureVpnService.ACTION_STOP)) - CaptureStatus.markStopped() } /** Runs [action] now, or after the user agrees to stop the running capture; [reason] says why. */ fun whenStopped(activity: Activity, @StringRes reason: Int, action: () -> Unit) { - if (!captureActive) { - action() - return + when (CaptureStatus.snapshot().phase) { + CapturePhase.STOPPED -> action() + // Already stopping: nothing to ask, only the end of its teardown to wait for. + CapturePhase.STOPPING -> afterTeardown(activity, action) + CapturePhase.STARTING, CapturePhase.RUNNING -> + Dialogs.confirm(activity, R.string.capture_running_title, reason, R.string.stop_capture_and_continue) { + stop(activity) + afterTeardown(activity, action) + } + } + } + + /** Continues with [action] once capture is STOPPED, or explains why it could not within the timeout. */ + private fun afterTeardown(activity: Activity, action: () -> Unit) { + var settled = false + var cancelWait: (() -> Unit)? = null + val timeout = Runnable { + if (settled) return@Runnable + settled = true + cancelWait?.invoke() + if (!activity.isFinishing && !activity.isDestroyed) { + Dialogs.message( + activity, + activity.getString(R.string.capture_stop_timeout_title), + activity.getString(R.string.capture_stop_timeout_message), + ) + } } - Dialogs.confirm(activity, R.string.capture_running_title, reason, R.string.stop_capture_and_continue) { - stop(activity) - action() + // Posted first, so a wait that is already satisfied below can withdraw it. + handler.postDelayed(timeout, STOP_TIMEOUT_MS) + cancelWait = CaptureStatus.whenStopped { + if (settled) return@whenStopped + settled = true + handler.removeCallbacks(timeout) + // A screen that left meanwhile no longer owns the operation it asked for. + if (!activity.isFinishing && !activity.isDestroyed) action() } } } diff --git a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt index 5426197..57547ec 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt @@ -159,7 +159,9 @@ internal object Feedback { } fun show(context: Context, @StringRes message: Int, long: Boolean = false) { - show(context, context.getString(message), long) + // Only Activities carry the app's own language; anything else would show the device's. + val localized = context as? android.app.Activity ?: dev.gf2log.app.LanguagePreferences.wrap(context.applicationContext) + show(context, localized.getString(message), long) } /** Copies plain [value] under the clipboard [label] and confirms with [confirmation]. */ diff --git a/app/src/main/java/dev/gf2log/app/ui/Screen.kt b/app/src/main/java/dev/gf2log/app/ui/Screen.kt index 5d331c2..9db9a5c 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Screen.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Screen.kt @@ -39,6 +39,10 @@ internal class Screen( } init { + // Minimum heights count padding, so the system-bar insets are added on top of them; + // otherwise the bars' own content would shrink by the inset. + val topBarHeight = topBar.minimumHeight + val bottomBarHeight = bottomBar?.minimumHeight ?: 0 ViewCompat.setOnApplyWindowInsetsListener(root) { view, windowInsets -> val bars = windowInsets.getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), @@ -46,8 +50,10 @@ internal class Screen( val ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) view.setPadding(bars.left, 0, bars.right, 0) topBar.setPadding(topBar.paddingLeft, bars.top, topBar.paddingRight, 0) + topBar.minimumHeight = topBarHeight + bars.top bottomInset = if (bottomBar != null) { bottomBar.setPadding(0, 0, 0, bars.bottom) + bottomBar.minimumHeight = bottomBarHeight + bars.bottom maxOf(0, ime.bottom - bars.bottom) } else { maxOf(bars.bottom, ime.bottom) diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_1.png b/app/src/main/res/drawable-nodpi/guild_flag_bg_1.png deleted file mode 100644 index 89c9e06..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_bg_1.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_1.webp b/app/src/main/res/drawable-nodpi/guild_flag_bg_1.webp new file mode 100644 index 0000000..3054b50 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_bg_1.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_2.png b/app/src/main/res/drawable-nodpi/guild_flag_bg_2.png deleted file mode 100644 index a3f38b6..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_bg_2.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_2.webp b/app/src/main/res/drawable-nodpi/guild_flag_bg_2.webp new file mode 100644 index 0000000..b216315 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_bg_2.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_3.png b/app/src/main/res/drawable-nodpi/guild_flag_bg_3.png deleted file mode 100644 index afdc12f..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_bg_3.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_3.webp b/app/src/main/res/drawable-nodpi/guild_flag_bg_3.webp new file mode 100644 index 0000000..452f96e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_bg_3.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_4.png b/app/src/main/res/drawable-nodpi/guild_flag_bg_4.png deleted file mode 100644 index 78180cd..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_bg_4.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_4.webp b/app/src/main/res/drawable-nodpi/guild_flag_bg_4.webp new file mode 100644 index 0000000..af947a7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_bg_4.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_5.png b/app/src/main/res/drawable-nodpi/guild_flag_bg_5.png deleted file mode 100644 index cb689f1..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_bg_5.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_5.webp b/app/src/main/res/drawable-nodpi/guild_flag_bg_5.webp new file mode 100644 index 0000000..ff2c1c9 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_bg_5.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_6.png b/app/src/main/res/drawable-nodpi/guild_flag_bg_6.png deleted file mode 100644 index f193f59..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_bg_6.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_bg_6.webp b/app/src/main/res/drawable-nodpi/guild_flag_bg_6.webp new file mode 100644 index 0000000..d0fdbb1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_bg_6.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_1.png b/app/src/main/res/drawable-nodpi/guild_flag_mark_1.png deleted file mode 100644 index 2cd70c8..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_mark_1.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_1.webp b/app/src/main/res/drawable-nodpi/guild_flag_mark_1.webp new file mode 100644 index 0000000..76a0212 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_mark_1.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_2.png b/app/src/main/res/drawable-nodpi/guild_flag_mark_2.png deleted file mode 100644 index e33ba15..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_mark_2.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_2.webp b/app/src/main/res/drawable-nodpi/guild_flag_mark_2.webp new file mode 100644 index 0000000..fa00ef4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_mark_2.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_3.png b/app/src/main/res/drawable-nodpi/guild_flag_mark_3.png deleted file mode 100644 index dbe93ce..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_mark_3.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_3.webp b/app/src/main/res/drawable-nodpi/guild_flag_mark_3.webp new file mode 100644 index 0000000..bbc9f7d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_mark_3.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_4.png b/app/src/main/res/drawable-nodpi/guild_flag_mark_4.png deleted file mode 100644 index c01ed55..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_mark_4.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_4.webp b/app/src/main/res/drawable-nodpi/guild_flag_mark_4.webp new file mode 100644 index 0000000..6fca7f6 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_mark_4.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_5.png b/app/src/main/res/drawable-nodpi/guild_flag_mark_5.png deleted file mode 100644 index 8d92358..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_mark_5.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_5.webp b/app/src/main/res/drawable-nodpi/guild_flag_mark_5.webp new file mode 100644 index 0000000..0435ba7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_mark_5.webp differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_6.png b/app/src/main/res/drawable-nodpi/guild_flag_mark_6.png deleted file mode 100644 index b5e85cb..0000000 Binary files a/app/src/main/res/drawable-nodpi/guild_flag_mark_6.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/guild_flag_mark_6.webp b/app/src/main/res/drawable-nodpi/guild_flag_mark_6.webp new file mode 100644 index 0000000..df14d53 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/guild_flag_mark_6.webp differ diff --git a/app/src/main/res/drawable-nodpi/ic_launcher_art.png b/app/src/main/res/drawable-nodpi/ic_launcher_art.png deleted file mode 100644 index aa2ee0e..0000000 Binary files a/app/src/main/res/drawable-nodpi/ic_launcher_art.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/ic_launcher_art.webp b/app/src/main/res/drawable-nodpi/ic_launcher_art.webp new file mode 100644 index 0000000..1777511 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_launcher_art.webp differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi/ic_launcher.xml diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 4a61acc..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 4cbbae3..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 19f62cb..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 1f418f7..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 0ab93df..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index da8fe07..fb3f4ec 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -5,6 +5,7 @@ 캡처 상태 준비됨 캡처 중 + 캡처 중지 중 홈 서클 주간 @@ -103,6 +104,9 @@ 멤버 삭제 멤버를 삭제하면 가입 이력, 개인 메모, 주간 과업표 수정 내용이 영구적으로 삭제됩니다. 이후 명단에서 다시 추가될 수 있지만 삭제된 정보는 복구되지 않습니다. 멤버를 삭제했습니다 + 패킷이 너무 커서 클립보드에 복사할 수 없습니다. + 전체 %2$s자 중 처음 %1$s자만 표시합니다. + 선택한 패킷을 삭제하지 못했습니다 멤버를 삭제할 수 없습니다 탈퇴한 멤버 추가 메모 @@ -345,6 +349,7 @@ 저장된 웹훅 제거 Discord 웹훅을 안전하게 저장했습니다. discord.com의 유효한 HTTPS 수신 웹훅 URL을 입력하세요. + 저장된 Discord 웹훅을 제거할까요? CSV를 보내려면 다시 붙여 넣어야 합니다. 저장된 Discord 웹훅을 제거했습니다. 저장된 Discord 웹훅을 제거할 수 없습니다. 원본 CSV 전송 @@ -403,7 +408,7 @@ Lv. %1$d 스냅샷을 비교하는 중… 멤버를 불러오는 중… - %1$s: 이번 주 공적 +%2$d, 누적 공적 +%3$d, 흙먼지 점수 +%4$d + %1$s: 이번 주 공적 +%2$s, 누적 공적 +%3$d, 흙먼지 점수 +%4$s 수치 변화 · %1$d 게임 닉네임 대신 모든 화면에 표시됩니다. %2$d명 중 %1$d번째, %3$s @@ -517,6 +522,7 @@ 백업을 복원하는 중… Android가 VPN을 시작하기를 기다리는 중입니다. 캡처 중에 게임의 서클 › 멤버와 동향 화면을 여세요. + 캡처를 끝내기 전에 마지막 패킷을 저장하는 중입니다. 수집됨 변경 서클 활동 @@ -597,6 +603,8 @@ 적용 후 다음 날 캡처 중입니다 캡처 중지 후 계속 + 캡처를 아직 중지하는 중입니다 + 캡처가 끝나지 않아 아무것도 바꾸지 않았습니다. 홈에 준비됨이 표시되면 다시 시도하세요. 출석이 아니요이면 과업을 예로 둘 수 없습니다. 대기 모드 켜기 대기 모드 실행 중 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1c71d7..ef8188d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ Capture status Ready Capturing + Stopping capture Home Platoon Weekly @@ -103,6 +104,9 @@ Delete member Deleting this member permanently removes their history, personal notes, and weekly edits. A later roster may add the member again, but the deleted details cannot be recovered. Member deleted + This packet is too large to copy to the clipboard. + Showing the first %1$s of %2$s characters. + Unable to delete the selected packets Unable to delete member Add withdrawn member Notes @@ -349,6 +353,7 @@ Remove saved webhook Discord webhook saved securely. Enter a valid HTTPS incoming-webhook URL on discord.com. + Remove the saved Discord webhook? You will need to paste it again to send CSV files. Saved Discord webhook removed. Unable to remove the saved Discord webhook. Send original CSV @@ -407,7 +412,7 @@ Lv. %1$d Comparing snapshots… Loading members… - %1$s: merit this week +%2$d, total merit +%3$d, Gunsmoke score +%4$d + %1$s: merit this week +%2$s, total merit +%3$d, Gunsmoke score +%4$s Counter changes · %1$d Shown everywhere in place of the in-game name. %1$d of %2$d, %3$s @@ -524,6 +529,7 @@ Restoring backup… Waiting for Android to start the VPN. Open the game\'s Platoon › Members and Updates pages while capture runs. + Saving the last packets before capture ends. captured Change Platoon activity @@ -605,6 +611,8 @@ Apply, next day Capture is running Stop capture + Capture is still stopping + Capture didn\'t finish stopping, so nothing was changed. Wait until Home shows Ready, then try again. Patrol can\'t be yes while login is no. Turn on standby mode Standby mode is on diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index a0a39d0..09587af 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -3,4 +3,5 @@ + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9de2e22..ac05322 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -4,10 +4,12 @@ + + diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt index 12e4ca8..c1b425e 100644 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt @@ -84,6 +84,99 @@ class CaptureStatusTest { assertEquals(types.toSet(), CaptureStatus.snapshot().guided?.capturedPayloadTypes) } + @Test + fun stoppedIsPublishedOnlyAfterTeardownAndKeepsTheStopNotice() { + CaptureStatus.markRunning(listOf("pkg")) + + CaptureStatus.markStopping(CaptureNotice.STOPPED_UNEXPECTEDLY) + val stopping = CaptureStatus.snapshot() + assertEquals(CapturePhase.STOPPING, stopping.phase) + assertEquals(emptyList(), stopping.capturedTargets) + assertEquals(CaptureNotice.STOPPED_UNEXPECTEDLY, stopping.notice?.notice) + + CaptureStatus.markTornDown() + val stopped = CaptureStatus.snapshot() + assertEquals(CapturePhase.STOPPED, stopped.phase) + assertEquals(CaptureNotice.STOPPED_UNEXPECTEDLY, stopped.notice?.notice) + } + + @Test + fun teardownLeavesAPhaseANewerStartAlreadyOwns() { + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.markStopping(notice = null) + // A start that arrives during teardown restarts once it finishes. + CaptureStatus.markStarting() + + CaptureStatus.markTornDown() + + assertEquals(CapturePhase.STARTING, CaptureStatus.snapshot().phase) + } + + @Test + fun everyRunIsCountedSoAStaleRunningPhaseIsDistinguishable() { + val before = CaptureStatus.snapshot().runCount + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.markStopping(notice = null) + CaptureStatus.markTornDown() + CaptureStatus.markRunning(listOf("pkg")) + + assertEquals(before + 2, CaptureStatus.snapshot().runCount) + } + + @Test + fun clearingAnIdleNoticeNeverEndsAnActiveCapture() { + CaptureStatus.markStopped(CaptureNotice.NATIVE_START_FAILED) + CaptureStatus.clearIdleNotice() + assertNull(CaptureStatus.snapshot().notice) + + CaptureStatus.markStarting() + CaptureStatus.clearIdleNotice() + assertEquals(CapturePhase.STARTING, CaptureStatus.snapshot().phase) + } + + @Test + fun whenStoppedRunsImmediatelyWhileStopped() { + var runs = 0 + CaptureStatus.whenStopped { runs++ } + + assertEquals(1, runs) + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.markStopped() + assertEquals(1, runs) + } + + @Test + fun whenStoppedWaitsThroughTheWholeTeardownAndRunsOnce() { + CaptureStatus.markRunning(listOf("pkg")) + var runs = 0 + CaptureStatus.whenStopped { runs++ } + + CaptureStatus.markStopping(notice = null) + CaptureStatus.report(CaptureNotice.TRAFFIC, "1", "0", "0", "0", "0") + assertEquals(0, runs) + + CaptureStatus.markTornDown() + assertEquals(1, runs) + + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.markStopping(notice = null) + CaptureStatus.markTornDown() + assertEquals(1, runs) + } + + @Test + fun aCancelledStopWaitNeverRuns() { + CaptureStatus.markRunning(listOf("pkg")) + var runs = 0 + val cancel = CaptureStatus.whenStopped { runs++ } + + cancel() + CaptureStatus.markStopping(notice = null) + CaptureStatus.markTornDown() + + assertEquals(0, runs) + } + @Test fun listenersReceiveTheCurrentSnapshotImmediatelyAndAfterChanges() { val received = mutableListOf() diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt index 70bbeff..c9947d4 100644 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt @@ -29,4 +29,23 @@ class CaptureTrafficStatsTest { assertEquals("done", diagnostics.finalStatus) assertNull(stats.finish("again")) } + + @Test + fun unknownTypesBeyondTheCapAreCountedTogether() { + val stats = CaptureTrafficStats() + stats.begin(Instant.EPOCH) + val cap = CaptureTrafficStats.MAX_UNKNOWN_TYPES + (0 until cap + 100).forEach { stats.recordUnknown(it) } + stats.recordUnknown(0) + stats.recordUnknown(cap + 500) + + val entries = requireNotNull(stats.finish("done")).unknownPayloads.split(", ") + assertEquals(cap + 1, entries.size) + assertEquals("0:2", entries.first()) + assertEquals("*:101", entries.last()) + + stats.begin(Instant.EPOCH) + stats.recordUnknown(7) + assertEquals("7:1", requireNotNull(stats.finish("again")).unknownPayloads) + } } diff --git a/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt b/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt index 35eaa89..7e075d5 100644 --- a/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt @@ -9,7 +9,6 @@ import java.time.Clock import java.time.Instant import java.time.ZoneOffset import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule @@ -142,8 +141,39 @@ class GuildMembersCsvWriterTest { ) assertEquals(1, completed.size) - assertEquals(1, output.listFiles().orEmpty().count { it.extension == "partial" }) - assertFalse(output.listFiles().orEmpty().any { it.extension == "csv" }) + assertTrue(output.listFiles().orEmpty().isEmpty()) + } + + @Test + fun stalePartialsAreDeletedWithoutTouchingPublishedRosters() { + val output = temporaryFolder.newFolder("stale-partials") + val published = output.resolve("gf2log_platoonmembers_20260721T191109Z.csv").apply { writeText("x") } + output.resolve("gf2log_platoonmembers_20260721T191110Z.csv.partial").writeText("") + output.resolve("gf2log_platoonmembers_20260721T191111Z_2.csv.partial").writeText("half") + + GuildMembersCsvWriter.deleteStalePartials(output) + GuildMembersCsvWriter.deleteStalePartials(output.resolve("missing")) + + assertEquals(listOf(published.name), output.listFiles().orEmpty().map { it.name }) + } + + @Test + fun boundsApplyToTheDeduplicatedCompletedRosterOnly() { + val output = temporaryFolder.newFolder("bounded-after-dedupe") + val writer = GuildMembersCsvWriter(output) + val repeated = (1..GuildMembersCsv.MAX_ROSTER_MEMBERS).flatMap { index -> + List(2) { member(index.toUInt(), "Member $index") } + } + val oversized = (1..(GuildMembersCsv.MAX_ROSTER_MEMBERS + 1)).map { index -> + member(index.toUInt(), "Member $index") + } + + val saved = writer.accept(payload(42, end = true, *repeated.toTypedArray())) + // A roster cut short is discarded before any bound applies, so it reports no failure. + val discarded = writer.accept(payload(0, end = true, *oversized.toTypedArray())) + + assertEquals(GuildMembersCsv.MAX_ROSTER_MEMBERS, saved?.rowCount) + assertNull(discarded) } @Test diff --git a/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt b/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt index 3c01461..9b65cd9 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt @@ -20,11 +20,11 @@ class PendingPlatoonAdmissionStoreTest { } @Test - fun listenersSeeVisibleCandidatesChangeWithoutPolling() { + fun listenersSeeCandidatesChangeWithoutPolling() { MainThread.testExecutor = { it() } val observed = mutableListOf>() PendingPlatoonAdmissionStore.addListener { summaries -> - observed += summaries.map { it.profile.platoonName } + observed += summaries.map { if (it.admitting) "${it.profile.platoonName}…" else it.profile.platoonName } } val token = requireNotNull( PendingPlatoonAdmissionStore.begin( @@ -38,8 +38,9 @@ class PendingPlatoonAdmissionStoreTest { PendingPlatoonAdmissionStore.releaseClaim(token) PendingPlatoonAdmissionStore.discard(token) + // A claimed candidate stays listed, so its open prompt is not closed mid-confirmation. assertEquals( - listOf(emptyList(), listOf("Owls"), emptyList(), listOf("Owls"), emptyList()), + listOf(emptyList(), listOf("Owls"), listOf("Owls…"), listOf("Owls"), emptyList()), observed, ) } @@ -105,7 +106,7 @@ class PendingPlatoonAdmissionStoreTest { PendingPlatoonAdmissionStore.releaseClaim(token) assertNotNull(PendingPlatoonAdmissionStore.claim(token)) assertEquals(setOf(7L), PendingPlatoonAdmissionStore.complete(token)) - assertFalse(PendingPlatoonAdmissionStore.contains(token)) + assertTrue(PendingPlatoonAdmissionStore.summaries().none { it.token == token }) } @Test @@ -118,22 +119,32 @@ class PendingPlatoonAdmissionStoreTest { ).token, ) - assertNull(PendingPlatoonAdmissionStore.withClaim(token) { false }) - assertNotNull(PendingPlatoonAdmissionStore.summary(token)) + assertNull( + PendingPlatoonAdmissionStore.withClaim(token) { CaptureNotice.ADMISSION_REPLAY_FAILED }, + ) + val failed = PendingPlatoonAdmissionStore.summaries().single() + assertEquals(CaptureNotice.ADMISSION_REPLAY_FAILED, failed.failure) + assertFalse(failed.admitting) + assertEquals(1, failed.attempts) assertTrue( runCatching { PendingPlatoonAdmissionStore.withClaim(token) { error("registry unavailable") } }.isFailure, ) - assertNotNull(PendingPlatoonAdmissionStore.summary(token)) + val thrown = PendingPlatoonAdmissionStore.summaries().single() + assertEquals(CaptureNotice.ADMISSION_INVALID, thrown.failure) + assertEquals(2, thrown.attempts) assertEquals(setOf(4L), PendingPlatoonAdmissionStore.withClaim(token) { claim -> assertNull(PendingPlatoonAdmissionStore.summary(claim.summary.token)) - true + val running = PendingPlatoonAdmissionStore.summaries().single() + assertTrue(running.admitting) + assertNull(running.failure) + null }) - assertFalse(PendingPlatoonAdmissionStore.contains(token)) - assertNull(PendingPlatoonAdmissionStore.withClaim(token) { true }) + assertTrue(PendingPlatoonAdmissionStore.summaries().none { it.token == token }) + assertNull(PendingPlatoonAdmissionStore.withClaim(token) { null }) } @Test @@ -188,7 +199,7 @@ class PendingPlatoonAdmissionStoreTest { PendingPlatoonAdmissionStore.OfferResult.Overflow(setOf(9L)), overflow, ) - assertFalse(PendingPlatoonAdmissionStore.contains(token)) + assertTrue(PendingPlatoonAdmissionStore.summaries().none { it.token == token }) } @Test diff --git a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverPropertyTest.kt b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverPropertyTest.kt index 9b05e61..6674027 100644 --- a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverPropertyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverPropertyTest.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import java.time.Instant import java.time.LocalDate import java.time.ZoneId import kotlin.random.Random @@ -79,6 +80,205 @@ class GunsmokeWeekSolverPropertyTest { assertTrue("generated cases must exercise score publication", publishedScoreChecks > 0) } + /** + * Automatic recording captures every member once a day at 04:56, just before the reset, while + * fifty members play one or two attempts a day. Each interval can still split between the + * previous day's last minutes and the new day, so the days stay lower bounds. The detailed + * search must hand over to interval bounds instead of dropping the rest of the week: every + * day keeps its Daily Patrol and a score floor, and the weekly totals stay exact. + */ + @Test(timeout = 10_000) + fun dailyPreResetRecordingKeepsEveryDayAndExactWeeklyTotals() { + val random = Random(7) + val truths = List(50) { + List(7) { + val attempts = random.nextInt(1, 3) + RecordedDay(attemptScores = List(attempts) { random.nextLong(6_000, 10_001) }, patrol = true) + } + } + val snapshots = (0..7).map { completedDays -> + val capturedAt = periodStart.plusDays(completedDays.toLong()).atTime(4, 56).atZone(zone).toInstant() + PlatoonSnapshot( + id = completedDays.toLong(), + capturedAt = capturedAt, + members = truths.mapIndexed { index, days -> + recordedMember(index + 1L, days, completedDays, 10_000L, capturedAt.minusSeconds(3_600)) + }, + ) + } + + val report = WeeklyReportBuilder.build( + referenceDay = periodStart, + zoneId = zone, + snapshots = snapshots, + asOf = periodStart.plusDays(8).atTime(6, 0).atZone(zone).toInstant(), + ) + + assertEquals(50, report.members.size) + report.members.forEach { row -> + val days = truths[(row.uid - 1).toInt()] + val label = "member ${row.uid}" + assertConservative(label, row.days, days) + row.days.forEach { cell -> + assertEquals("$label login on ${cell.gameDay}", true, cell.attended) + assertEquals("$label patrol on ${cell.gameDay}", true, cell.dailyPatrol) + assertTrue("$label merit floor on ${cell.gameDay}", requireNotNull(cell.meritDelta) >= 90L) + assertTrue("$label score floor on ${cell.gameDay}", cell.scoreDelta != null) + } + // The Monday weekly reset still closes Sunday exactly. + assertEquals(MetricCertainty.EXACT, row.days.first().meritCertainty) + assertEquals(MetricCertainty.EXACT, row.days.first().attemptsCertainty) + assertEquals(label, days.sumOf(RecordedDay::merit), row.totalMerit) + assertEquals(label, MetricCertainty.EXACT, row.totalMeritCertainty) + assertEquals(label, days.sumOf(RecordedDay::attempts), row.totalAttempts) + assertEquals(label, MetricCertainty.EXACT, row.totalAttemptsCertainty) + assertEquals(label, 7, row.loginDays) + assertEquals(label, MetricCertainty.EXACT, row.loginDaysCertainty) + assertEquals(label, 7, row.patrolDays) + assertEquals(label, MetricCertainty.EXACT, row.patrolDaysCertainty) + } + } + + /** + * Daily, twice-daily, sparse, and exact-reset recording routines with any attempt count and + * high score never publish a value the true week contradicts. + */ + @Test(timeout = 60_000) + fun recordingRoutinesAroundTheResetRemainConservative() { + repeat(160) { caseIndex -> + val random = Random(caseIndex * 7_919 + 13) + val highScore = listOf(10_000L, 10_545L, 7_985L)[random.nextInt(3)] + val days = List(7) { + val attempts = random.nextInt(0, 4) + val attended = attempts > 0 || random.nextBoolean() + RecordedDay( + attemptScores = List(attempts) { random.nextLong(highScore / 2, highScore + 1) }, + attended = attended, + patrol = attended && random.nextInt(4) != 0, + ) + } + val routine = caseIndex % 4 + val captures = buildList { + (0..7).forEach { completedDays -> + val time = if (routine == 3) 5 to 0 else 4 to 56 + if (routine != 2 || completedDays == 0 || completedDays == 7 || random.nextBoolean()) { + add(periodStart.plusDays(completedDays.toLong()).atTime(time.first, time.second) to completedDays) + } + if (routine == 1 && completedDays < 7) { + add(periodStart.plusDays(completedDays.toLong()).atTime(12, 0) to completedDays) + } + } + } + val snapshots = captures.map { (localTime, completedDays) -> + val capturedAt = localTime.atZone(zone).toInstant() + // Every attended day logs in at 09:00. + val lastLogin = (0..6).asSequence() + .filter { index -> days[index].attended } + .map { index -> periodStart.plusDays(index.toLong()).atTime(9, 0).atZone(zone).toInstant() } + .lastOrNull { it.isBefore(capturedAt) } + PlatoonSnapshot( + id = 0, + capturedAt = capturedAt, + members = listOf( + recordedMember( + uid = 1L, + days = days, + completedDays = completedDays, + highScore = highScore, + lastLogin = lastLogin, + // A noon capture sees the morning half of that day's attempts. + partialDay = days.getOrNull(completedDays).takeIf { localTime.hour == 12 }, + ), + ), + ) + } + val facts = if (caseIndex % 5 == 0) { + days.indices.filter { days[it].patrol }.map { index -> + DailyPatrolFact(1L, periodStart.plusDays(index.toLong()).atTime(10, 0).atZone(zone).toInstant()) + } + } else { + emptyList() + } + + val cells = WeeklyReportBuilder.build( + referenceDay = periodStart, + zoneId = zone, + snapshots = snapshots, + dailyPatrolFacts = facts, + asOf = periodStart.plusDays(8).atTime(6, 0).atZone(zone).toInstant(), + ).members.single().days + + assertConservative("routine $routine case $caseIndex", cells, days) + } + } + + private fun assertConservative( + label: String, + cells: List, + days: List, + ) { + cells.zip(days).forEach { (cell, actual) -> + val at = "$label on ${cell.gameDay}" + cell.meritDelta?.let { assertTrue("$at merit floor", it <= actual.merit) } + cell.scoreDelta?.let { assertTrue("$at score floor", it <= actual.score) } + cell.attempts?.let { assertTrue("$at attempts floor", it <= actual.attempts) } + if (cell.meritCertainty == MetricCertainty.EXACT) assertEquals("$at merit", actual.merit, cell.meritDelta) + if (cell.scoreCertainty == MetricCertainty.EXACT) assertEquals("$at score", actual.score, cell.scoreDelta) + if (cell.attemptsCertainty == MetricCertainty.EXACT) assertEquals("$at attempts", actual.attempts, cell.attempts) + cell.attended?.let { assertEquals("$at login", actual.attended, it) } + cell.dailyPatrol?.let { assertEquals("$at patrol", actual.patrol, it) } + } + } + + /** + * One member's counters after [completedDays] whole days, plus the morning half of + * [partialDay] when the capture falls inside it. Merit This Week restarts on Monday. + */ + private fun recordedMember( + uid: Long, + days: List, + completedDays: Int, + highScore: Long, + lastLogin: Instant?, + partialDay: RecordedDay? = null, + ): SnapshotMember { + val morning = partialDay?.let { day -> + RecordedDay(day.attemptScores.take(day.attempts / 2), attended = day.attended, patrol = false) + } + val done = days.take(completedDays) + val partialMerit = morning?.merit ?: 0L + return SnapshotMember( + uid = uid, + name = "Member $uid", + level = 60, + weeklyMerit = when { + completedDays == 0 -> 540L + partialMerit + completedDays == 1 && partialDay == null -> 540L + done.first().merit + else -> done.drop(1).sumOf(RecordedDay::merit) + partialMerit + }, + totalMerit = 100_000L + done.sumOf(RecordedDay::merit) + partialMerit, + highScore = highScore, + totalScore = done.sumOf(RecordedDay::score) + (morning?.score ?: 0L), + lastLogin = lastLogin?.epochSecond ?: 0L, + ) + } + + private data class RecordedDay( + val attemptScores: List, + val attended: Boolean = true, + val patrol: Boolean, + ) { + val attempts: Int + get() = attemptScores.size + val score: Long + get() = attemptScores.sum() + val merit: Long + get() = attempts * ActivityInference.MERIT_PER_ATTEMPT + + attemptScores.sumOf { it / ActivityInference.SCORE_POINTS_PER_MERIT } + + (if (attended) ActivityInference.LOGIN_MERIT else 0L) + + (if (patrol) ActivityInference.DAILY_PATROL_MERIT else 0L) + } + private fun snapshots(caseIndex: Int, truth: List): List { var totalMerit = 100_000L var totalScore = 0L diff --git a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt index 886cc31..2c3a56c 100644 --- a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt @@ -335,7 +335,7 @@ class GunsmokeWeekSolverTest { uid = UID, days = days, zoneId = zone, - snapshots = checkpoints.filterIndexed { index, _ -> index != 3 }, + snapshots = checkpoints, cells = cells, dailyPatrolFacts = emptyList(), ) @@ -343,6 +343,76 @@ class GunsmokeWeekSolverTest { assertEquals(MetricCertainty.EXACT, missingWednesday.totals.attemptsCertainty) } + /** + * The final event packet arrives at 02:30 after a Saturday login without patrol. Login and + * Daily Patrol stay open until 05:00, so the weekly aggregate must not publish a Saturday + * patrol miss while it reports the patrol total as a lower bound. + */ + @Test + fun finalEventAggregateKeepsTheOpenSaturdayPatrolUnknown() { + val days = (0L..6L).map(start::plusDays) + val attempts = listOf(3, 3, 3, 3, 3, 2, 0) + val dailyMerits = attempts.map { count -> + if (count == 0) LOGIN_MERIT else 90L + count * (ActivityInference.MERIT_PER_ATTEMPT + 1_000L) + } + val dailyScores = attempts.map { it * 10_000L } + val totalMerits = dailyMerits.runningReduce(Long::plus) + val totalScores = dailyScores.runningReduce(Long::plus) + val checkpoints = days.mapIndexed { index, day -> + val capturedAt = if (index == days.lastIndex) { + day.plusDays(1).atTime(2, 30).atZone(zone).toInstant() + } else { + day.plusDays(1).atTime(4, 0).atZone(zone).toInstant() + } + PlatoonSnapshot( + id = index.toLong(), + capturedAt = capturedAt, + members = listOf( + member( + totalMerit = totalMerits[index], + totalScore = totalScores[index], + weeklyMerit = if (index == 0) { + totalMerits[0] + } else { + totalMerits[index] - totalMerits[0] + }, + ), + ), + ) + } + val cells = days.mapIndexed { index, day -> + WeeklyReportBuilder.DayCell( + gameDay = day, + meritDelta = dailyMerits[index], + scoreDelta = dailyScores[index], + inference = ActivityInference.infer(dailyMerits[index], dailyScores[index], gunsmokeActive = true), + evidence = DailyEvidence.PARTIAL_DAY, + hasDailyPatrolFact = index == 3, + hasFinalGunsmokeScore = index == days.lastIndex, + hasLoginFact = index == days.lastIndex, + isGunsmokeWeek = true, + metricObservedAt = checkpoints[index].capturedAt, + ) + } + + // Without a Wednesday capture the week is solved by the final-event aggregate; the + // Wednesday patrol fact and the score ceiling leave no other day to hold a patrol. + val resolution = GunsmokeWeekSolver.resolve( + uid = UID, + days = days, + zoneId = zone, + snapshots = checkpoints.filterIndexed { index, _ -> index != 3 }, + cells = cells, + dailyPatrolFacts = listOf(DailyPatrolFact(UID, Instant.parse("2026-07-22T03:00:00Z"))), + ) + + val totals = requireNotNull(resolution.totals) + assertEquals(6, totals.patrolDays) + assertEquals(MetricCertainty.LOWER_BOUND, totals.patrolDaysCertainty) + assertEquals(true, resolution.cells.last().attended) + assertNull(resolution.cells.last().dailyPatrol) + } + @Test fun invalidNegativeCountersRemainConservative() { val days = (0L..6L).map(start::plusDays) diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipCorrelationWindowTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipCorrelationWindowTest.kt new file mode 100644 index 0000000..11d940b --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/MembershipCorrelationWindowTest.kt @@ -0,0 +1,53 @@ +package dev.gf2log.app.management + +import java.time.Instant +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MembershipCorrelationWindowTest { + private val previousRoster = Instant.parse("2026-08-01T00:00:00Z") + private val revealingRoster = Instant.parse("2026-08-01T06:00:00Z") + + @Test + fun `an activity between the two rosters can date the boundary`() { + assertTrue(admits(previousRoster.plusSeconds(1))) + assertTrue(admits(revealingRoster)) + } + + @Test + fun `an activity after the revealing roster cannot become the boundary`() { + // The roster at revealingRoster already showed the change, so a later fact is no proof. + assertFalse(admits(revealingRoster.plusMillis(1))) + assertFalse(admits(revealingRoster.plusSeconds(60L * 60L))) + } + + @Test + fun `an activity at or before the previous roster cannot become the boundary`() { + assertFalse(admits(previousRoster)) + assertFalse(admits(previousRoster.minusSeconds(60L))) + } + + @Test + fun `a sparse roster gap still bounds the lead to the correlation window`() { + val distantPrevious = revealingRoster.minusSeconds(3L * 24L * 60L * 60L) + val leadLimit = revealingRoster.minusMillis(MembershipCorrelationWindow.MAX_LEAD_MILLIS) + + assertTrue(MembershipCorrelationWindow.admits(leadLimit, revealingRoster, distantPrevious)) + assertFalse( + MembershipCorrelationWindow.admits( + leadLimit.minusMillis(1), + revealingRoster, + distantPrevious, + ), + ) + } + + @Test + fun `a boundary without a previous roster stays inferred`() { + assertFalse(MembershipCorrelationWindow.admits(revealingRoster, revealingRoster, null)) + } + + private fun admits(occurredAt: Instant) = + MembershipCorrelationWindow.admits(occurredAt, revealingRoster, previousRoster) +} diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonCsvImportStoreTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonCsvImportStoreTest.kt index 04c76c5..dbd48fd 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonCsvImportStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonCsvImportStoreTest.kt @@ -55,6 +55,20 @@ class PlatoonCsvImportStoreTest { store.retain(ByteArrayInputStream(ByteArray(2 * 1024 * 1024 + 1))) } + @Test + fun futureDatedCsvIsRejectedBeforeAnythingIsRetained() { + val directory = temporary.newFolder("future") + val store = PlatoonCsvImportStore(directory) { Instant.parse("2026-07-19T17:00:00Z") } + + val failure = runCatching { store.retain(ByteArrayInputStream(VALID_CSV.toByteArray())) } + + assertTrue(failure.exceptionOrNull() is IllegalArgumentException) + assertEquals(emptyList(), directory.listFiles().orEmpty().map { it.name }) + // Within the allowed clock skew the same file is accepted. + PlatoonCsvImportStore(directory) { Instant.parse("2026-07-19T18:40:00Z") } + .prepare(ByteArrayInputStream(VALID_CSV.toByteArray())) + } + @Test fun duplicateUidCsvIsRejectedBeforeAnythingIsRetained() { val directory = temporary.newFolder("duplicate-uids") diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt index 2e2011c..07f6910 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt @@ -89,16 +89,35 @@ class PlatoonPeriodsTest { seoul, ), ) - assertFalse( + // The Sunday 05:00 reset capture is the closing Saturday capture of the event week. + assertTrue( PlatoonPeriods.isFinalGunsmokeScoreCapture( saturday, Instant.parse("2026-07-25T20:00:00Z"), seoul, ), ) + assertFalse( + PlatoonPeriods.isFinalGunsmokeScoreCapture( + saturday, + Instant.parse("2026-07-25T20:00:01Z"), + seoul, + ), + ) assertEquals( saturday, PlatoonPeriods.gameDay(Instant.parse("2026-07-25T17:00:00Z"), seoul), ) } + + @Test + fun aCaptureExactlyAtTheResetRecordsThePreviousGameDay() { + val saturday = LocalDate.of(2026, 7, 25) + val reset = PlatoonPeriods.periodStartInstant(saturday.plusDays(1), seoul) + + assertEquals(saturday, PlatoonPeriods.checkpointDay(reset, seoul)) + assertEquals(saturday, PlatoonPeriods.checkpointDay(reset.minusMillis(1), seoul)) + assertEquals(saturday.plusDays(1), PlatoonPeriods.checkpointDay(reset.plusMillis(1), seoul)) + assertEquals(saturday.plusDays(1), PlatoonPeriods.gameDay(reset, seoul)) + } } diff --git a/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonCsvTest.kt b/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonCsvTest.kt index 650ef24..c25c8b9 100644 --- a/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonCsvTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonCsvTest.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.management import java.time.Instant +import java.time.ZoneId import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -19,12 +20,12 @@ class SnapshotComparisonCsvTest { member(3, "New", 50), ) - val csv = SnapshotComparisonCsv.format(SnapshotComparison.compare(older, newer)) + val csv = SnapshotComparisonCsv.format(SnapshotComparison.compare(older, newer, ZoneId.of("Asia/Seoul"))) assertEquals(SnapshotComparisonCsv.HEADER, csv.lineSequence().first()) assertTrue(csv.contains(",JOINED,3,New,")) assertTrue(csv.contains(",WITHDREW,1,Old,")) - assertTrue(csv.contains(",CHANGED,2,\"Member, Two\",90,90,0,false")) + assertTrue(csv.contains(",CHANGED,2,\"Member, Two\",90,90,0,false,EXACT,EXACT")) } @Test @@ -32,7 +33,7 @@ class SnapshotComparisonCsvTest { val older = snapshot("2026-07-20T00:00:00Z") val newer = snapshot("2026-07-21T00:00:00Z", member(3, "@SUM(A1)", 50)) - val csv = SnapshotComparisonCsv.format(SnapshotComparison.compare(older, newer)) + val csv = SnapshotComparisonCsv.format(SnapshotComparison.compare(older, newer, ZoneId.of("Asia/Seoul"))) assertTrue(csv.contains(",JOINED,3,'@SUM(A1),")) } diff --git a/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonTest.kt b/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonTest.kt index bd06938..3d46e2a 100644 --- a/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonTest.kt @@ -1,10 +1,13 @@ package dev.gf2log.app.management import java.time.Instant +import java.time.ZoneId import org.junit.Assert.assertEquals import org.junit.Test class SnapshotComparisonTest { + private val zone = ZoneId.of("Asia/Seoul") + @Test fun comparesRosterAndCounterChangesByUid() { val older = snapshot( @@ -18,11 +21,37 @@ class SnapshotComparisonTest { member(3, "Joined", 0, 0, 0), ) - val result = SnapshotComparison.compare(older, newer) + val result = SnapshotComparison.compare(older, newer, zone) assertEquals(listOf(3L), result.joined.map { it.uid }) assertEquals(listOf(1L), result.left.map { it.uid }) assertEquals(90, result.changes.single().weeklyMeritDelta) + assertEquals(MetricCertainty.EXACT, result.changes.single().weeklyMeritDeltaCertainty) + } + + @Test + fun aCounterThatComesBackHigherAfterItsResetIsALowerBound() { + // Sunday noon to Monday noon crosses the Monday 05:00 weekly reset. + val older = snapshot("2026-07-26T03:00:00Z", member(1, "Member", 540, 10_000, 0)) + val newer = snapshot("2026-07-27T03:00:00Z", member(1, "Member", 600, 10_690, 0)) + + val change = SnapshotComparison.compare(older, newer, zone).changes.single() + + assertEquals(600, change.weeklyMeritDelta) + assertEquals(MetricCertainty.LOWER_BOUND, change.weeklyMeritDeltaCertainty) + assertEquals(690, change.totalMeritDelta) + } + + @Test + fun gunsmokeScoreRestartsWithEachEvent() { + // The 2026-07-19 event ends; the next one starts on 2026-08-09 at 05:00. + val older = snapshot("2026-07-25T03:00:00Z", member(1, "Member", 0, 0, 30_000)) + val newer = snapshot("2026-08-09T03:00:00Z", member(1, "Member", 0, 0, 40_000)) + + val change = SnapshotComparison.compare(older, newer, zone).changes.single() + + assertEquals(40_000, change.totalScoreDelta) + assertEquals(MetricCertainty.LOWER_BOUND, change.totalScoreDeltaCertainty) } private fun snapshot(time: String, vararg members: SnapshotMember) = diff --git a/app/src/test/java/dev/gf2log/app/management/SnapshotReconcilerTest.kt b/app/src/test/java/dev/gf2log/app/management/SnapshotReconcilerTest.kt index 84031d8..32bf87a 100644 --- a/app/src/test/java/dev/gf2log/app/management/SnapshotReconcilerTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/SnapshotReconcilerTest.kt @@ -1,10 +1,34 @@ package dev.gf2log.app.management +import java.time.Instant import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class SnapshotReconcilerTest { + @Test + fun historicalOrderingComparesCaptureTimeThenSourceFileName() { + val latestAt = Instant.parse("2026-08-01T00:00:00Z") + val latestFile = "gf2log_platoonmembers_b.csv" + + assertTrue( + SnapshotReconciler.isHistorical(latestAt.minusMillis(1), "z.csv", latestAt, latestFile), + ) + assertFalse( + SnapshotReconciler.isHistorical(latestAt.plusMillis(1), "a.csv", latestAt, latestFile), + ) + assertTrue(SnapshotReconciler.isHistorical(latestAt, latestFile, latestAt, latestFile)) + assertTrue( + SnapshotReconciler.isHistorical(latestAt, "gf2log_platoonmembers_a.csv", latestAt, latestFile), + ) + assertFalse( + SnapshotReconciler.isHistorical(latestAt, "gf2log_platoonmembers_c.csv", latestAt, latestFile), + ) + // A latest roster without a source file sorts before every named file at its instant. + assertFalse(SnapshotReconciler.isHistorical(latestAt, "a.csv", latestAt, null)) + } + @Test fun firstSnapshotIsAnInitialRosterWithoutJoinEvents() { val result = SnapshotReconciler.reconcile( diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt index 3b14e3e..1556c54 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt @@ -144,6 +144,83 @@ class WeeklyHistoryWorkPolicyTest { assertEquals(listOf(Instant.parse("2026-07-31T12:00:00Z")), lookups) } + @Test + fun `a membership edit refreshes still-listed earlier weeks and every week showing it now`() { + val periods = WeeklyHistoryWorkPolicy.memberEditPeriodStarts( + edit = WeeklyHistoryWorkPolicy.MemberEdit.MEMBERSHIP, + showingBefore = setOf(laterWeek, closingWeek), + listedAfter = { listOf(openingWeek, laterWeek) }, + showingAfter = { listedWeeks -> + assertEquals(listOf(openingWeek, laterWeek), listedWeeks) + setOf(openingWeek) + }, + ) + + assertEquals(listOf(openingWeek, laterWeek), periods) + } + + @Test + fun `a display-only edit reuses the pre-scan instead of listing and scanning again`() { + val showing = setOf(laterWeek, closingWeek) + + val periods = WeeklyHistoryWorkPolicy.memberEditPeriodStarts( + edit = WeeklyHistoryWorkPolicy.MemberEdit.DISPLAY_ONLY, + showingBefore = showing, + listedAfter = { error("a name edit cannot change the listed weeks") }, + showingAfter = { error("a name edit cannot change the rosters") }, + ) + + assertEquals(listOf(closingWeek, laterWeek), periods) + assertEquals( + WeeklyHistoryWorkPolicy.memberEditPeriodStarts( + edit = WeeklyHistoryWorkPolicy.MemberEdit.MEMBERSHIP, + showingBefore = showing, + listedAfter = { listed }, + showingAfter = { showing }, + ), + periods, + ) + } + + @Test + fun `a removal refreshes only the earlier weeks still listed and scans no roster`() { + val showing = setOf(laterWeek, closingWeek) + val remaining = listOf(openingWeek, laterWeek) + + val periods = WeeklyHistoryWorkPolicy.memberEditPeriodStarts( + edit = WeeklyHistoryWorkPolicy.MemberEdit.REMOVAL, + showingBefore = showing, + listedAfter = { remaining }, + showingAfter = { error("a removed member is shown nowhere") }, + ) + + assertEquals(listOf(laterWeek), periods) + assertEquals( + WeeklyHistoryWorkPolicy.memberEditPeriodStarts( + edit = WeeklyHistoryWorkPolicy.MemberEdit.MEMBERSHIP, + showingBefore = showing, + listedAfter = { remaining }, + showingAfter = { emptySet() }, + ), + periods, + ) + } + + @Test + fun `a failed pre-scan refreshes every listed week for every edit`() { + WeeklyHistoryWorkPolicy.MemberEdit.entries.forEach { edit -> + assertEquals( + listed, + WeeklyHistoryWorkPolicy.memberEditPeriodStarts( + edit = edit, + showingBefore = null, + listedAfter = { listed }, + showingAfter = { error("no scoped scan without a pre-scan") }, + ), + ) + } + } + private fun affected(instant: Instant, nextSnapshot: Instant?) = WeeklyHistoryWorkPolicy.affectedPeriodStarts(sequenceOf(instant), utc, listed) { nextSnapshot } } diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyReportBuilderTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyReportBuilderTest.kt index af0882c..5f0462f 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyReportBuilderTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyReportBuilderTest.kt @@ -996,6 +996,84 @@ class WeeklyReportBuilderTest { assertNull(cells[2].dailyPatrol) } + @Test + fun nearResetBaselineTailCannotPushAStandardDayAboveTheDailyCap() { + fun sunday(totalAfter: Long, facts: List = emptyList()) = + WeeklyReportBuilder.build( + referenceDay = LocalDate.of(2026, 7, 26), + zoneId = zone, + snapshots = listOf( + // Saturday still earns 40 between this 04:50 capture and the reset. + snapshot("2026-07-25T19:50:00Z", weekly = 500, score = 0), + snapshot("2026-07-26T11:00:00Z", weekly = totalAfter, score = 0), + ), + dailyPatrolFacts = facts, + asOf = Instant.parse("2026-07-26T12:00:00Z"), + ).members.single().days.first() + + val tailAndPatrol = sunday(totalAfter = 630) + assertEquals(90L, tailAndPatrol.meritDelta) + assertEquals(MetricCertainty.EXACT, tailAndPatrol.meritCertainty) + + val patrolFact = sunday( + totalAfter = 630, + facts = listOf(DailyPatrolFact(1, Instant.parse("2026-07-26T01:00:00Z"))), + ) + assertEquals(90L, patrolFact.meritDelta) + assertEquals(MetricCertainty.EXACT, patrolFact.meritCertainty) + assertEquals(true, patrolFact.dailyPatrol) + + // 50 + 50 or 90 + 50: the split between the two days is unknown. + val ambiguous = sunday(totalAfter = 640) + assertNull(ambiguous.meritDelta) + assertEquals(MetricCertainty.UNKNOWN, ambiguous.meritCertainty) + } + + @Test + fun midWeekJoinerMeritIsNotCreditedToTheFirstObservedDay() { + val present = member(uid = 1, name = "Present", weekly = 90, score = 0) + val joiner = member(uid = 2, name = "Joiner", weekly = 90, score = 0) + val report = WeeklyReportBuilder.build( + referenceDay = LocalDate.of(2026, 7, 28), + zoneId = zone, + snapshots = listOf( + snapshotWithMembers("2026-07-27T03:00:00Z", present), + snapshotWithMembers("2026-07-28T03:00:00Z", present.copy(weeklyMerit = 180), joiner), + ), + asOf = Instant.parse("2026-07-28T04:00:00Z"), + ) + + // Absent on Monday at noon, the joiner can still have earned the 90 later on Monday. + val row = report.members.single { it.uid == 2L } + assertNull(row.days[1].meritDelta) + assertNull(row.days[2].meritDelta) + assertTrue(row.days[2].meritCertainty != MetricCertainty.EXACT) + assertEquals(90L, row.totalMerit) + assertEquals(MetricCertainty.LOWER_BOUND, row.totalMeritCertainty) + } + + @Test + fun sundayResetCaptureClosesTheStandardSaturday() { + val report = WeeklyReportBuilder.build( + referenceDay = LocalDate.of(2026, 8, 1), + zoneId = zone, + snapshots = listOf( + snapshot("2026-07-31T20:00:00Z", weekly = 320, score = 0), + snapshot("2026-08-01T20:00:00Z", weekly = 410, score = 0), + ), + asOf = Instant.parse("2026-08-01T21:00:00Z"), + ) + + val member = report.members.single() + val saturday = member.days.last() + assertTrue(saturday.hasClosingBoundary) + assertEquals(90L, saturday.meritDelta) + assertEquals(MetricCertainty.EXACT, saturday.meritCertainty) + assertEquals(true, saturday.attended) + assertEquals(true, saturday.dailyPatrol) + assertEquals(410L, member.totalMerit) + } + @Test fun openStandardDayShowsFiftyAsALowerBoundButNinetyAsTheCap() { val fifty = WeeklyReportBuilder.build( @@ -1195,6 +1273,30 @@ class WeeklyReportBuilderTest { assertEquals(DailyEvidence.INCOMPLETE_BOUNDARY, saturday.evidence) } + @Test + fun sundayResetCaptureIsTheFinalGunsmokeScoreOfTheEndingWeek() { + val report = WeeklyReportBuilder.build( + referenceDay = LocalDate.of(2026, 7, 25), + zoneId = zone, + snapshots = listOf( + snapshot("2026-07-24T20:00:00Z", weekly = 1_000, score = 10_000), + snapshot("2026-07-25T20:00:00Z", weekly = 1_150, score = 10_300), + ), + asOf = Instant.parse("2026-07-25T21:00:00Z"), + ) + + val member = report.members.single() + val saturday = member.days.last() + assertTrue(saturday.hasClosingBoundary) + assertTrue(saturday.hasFinalGunsmokeScore) + assertTrue(member.hasFinalGunsmokeScore) + assertEquals(10_300L, member.totalScore) + assertEquals(MetricCertainty.EXACT, member.totalScoreCertainty) + assertEquals(150L, saturday.meritDelta) + assertEquals(300L, saturday.scoreDelta) + assertEquals(MetricCertainty.EXACT, saturday.scoreCertainty) + } + @Test fun preTwoAmFinalSaturdayCaptureLeavesScoreOpen() { val report = WeeklyReportBuilder.build( diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyReportStateHolderTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyReportStateHolderTest.kt index 7786366..84d6086 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyReportStateHolderTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyReportStateHolderTest.kt @@ -2,26 +2,9 @@ package dev.gf2log.app.management import java.time.LocalDate import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue import org.junit.Test class WeeklyReportStateHolderTest { - @Test - fun onlyLatestResumedRequestCanApply() { - val state = WeeklyReportStateHolder(LocalDate.of(2026, 8, 10)) - - state.onResume() - val first = state.newRenderRequest() - val second = state.newRenderRequest() - - assertFalse(state.canApply(first.generation)) - assertTrue(state.canApply(second.generation)) - - state.onPause() - assertFalse(state.canApply(second.generation)) - } - @Test fun navigationUsesAdjacentReportBoundaries() { val state = WeeklyReportStateHolder(LocalDate.of(2026, 8, 10)) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e64c6c..ae40198 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,7 +63,7 @@ Recognized inner types: | 21960 | Platoon updates | | 23201 | Formations | -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. +Unknown payload types are skipped without allocation. Capture diagnostics count the first 32 distinct unknown types individually and the rest together. 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 @@ -78,7 +78,9 @@ For a previously unknown `(client package, Platoon ID)`, decoded payloads enter a bounded process-memory admission queue. Returning to the app presents the observed Platoon name, ID, and verified client, then requires one compatible server choice. Confirmation creates the profile and replays its buffered -payloads through the normal scoped ingestion pipeline. Explicit discard, +payloads through the normal scoped ingestion pipeline. The prompt stays open +while a confirmation runs; a failed one releases the candidate with its reason, +which the prompt shows before the user retries or discards. Explicit discard, candidate overflow, force-stop, or process death removes the unconfirmed data; it never enters parsed-packet history, SQLite, retained CSV, or preferences. HaoPlay candidates expose only its four known regions and Darkwinter candidates @@ -122,7 +124,10 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. lifecycle glue whose fields are main-thread only. - Stopping capture never blocks the main thread: the worker stops the native loop, drains already queued chunks, and clears per-run state; a start that - arrives during teardown waits for it to finish. + arrives during teardown waits for it to finish. The phase reads `STOPPING` + for that whole teardown and becomes `STOPPED` only once it completes, even + when the service is destroyed meanwhile, so nothing that waits for `STOPPED` + can overlap the native loop or the drain. - Before identity is established, each flow retains at most 32 decoded payload objects. Overflow rejects that flow until closure. The profile registry holds at most 16 bounded identities. @@ -130,13 +135,17 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. per candidate, and 128 payloads total. Overflow rejects every flow belonging to that candidate rather than partially persisting it. - A bound flow cannot change its Platoon identity. Admission failure clears its - session and quarantines the flow until native closure. One user-started + session and quarantines the flow until native closure. Replay binds every + claimed flow before routing any buffered payload, so a replay that fails has + persisted nothing and a retry cannot duplicate history or roster files. One user-started capture may admit at most one new profile per supported client. Explicit profile deletion removes one immutable scope behind two confirmations. - TLS, HTTP, and UDP payloads remain native and are excluded from the parser. A TCP flow is classified once, on its first payload in either direction, so a later plaintext segment that happens to begin with TLS-like bytes can never - disable inspection. + disable inspection. TLS means a complete record header (content type 20–23, + version 3.0–3.4, length 1 to 2^14 + 2048); HTTP means a request method or + status line, so a game frame sharing only the first two bytes stays inspected. - Outgoing plaintext chunks are used only for native flow classification. - Flow-close callbacks finalize any pending recognized payload before removing parser state and unconditionally remove metadata even when no parser existed. A rejected close callback @@ -166,7 +175,7 @@ preserving in-flow ordering. 1. Start forwarding using the supplied TUN file descriptor. 2. Stop and release native resources. -The listener reports candidate plaintext payloads after TCP reassembly, flow closure, unexpected native termination, and aggregate byte counters. Flow ids are unique for the life of the process, so a pending Platoon that outlives one capture can never refer to a flow of the next. Every upstream socket is passed through `VpnService.protect()` so forwarded traffic cannot re-enter the VPN. +The listener reports candidate plaintext payloads after TCP reassembly, flow closure, unexpected native termination, and aggregate byte counters. Start returns only after the native thread has attached to the JVM; a thread that cannot attach could never report its termination, so start fails instead. Flow ids are unique for the life of the process, so a pending Platoon that outlives one capture can never refer to a flow of the next. Every upstream socket is passed through `VpnService.protect()` so forwarded traffic cannot re-enter the VPN. The implementation uses zdtun rather than introducing a new TCP/IP stack. General PCAP export, nDPI classification, TLS decryption, root capture, remote @@ -254,7 +263,10 @@ plaintext TCP streams. Do not add pinning or anti-cheat bypasses. ends in a lock or a cancel that releases the screen, and the accessibility service that taps only while the game is in front, closes it from Recents, and locks the phone. It drives capture only through the `CaptureVpnService` - intents and learns of saved rosters from `capture.RosterEvents`. Scheduled + intents and learns of saved rosters from `capture.RosterEvents`. A run that + follows a stopped one sends its start even while the previous capture is + still stopping; the service restarts after teardown, and the run opens the + game only on a newer `RUNNING` run, never on the one it just stopped. Scheduled recording reuses the same state machine: an exact alarm, re-planned from the current Platoons before every run, opens the standby screen over a swipe lock screen, and only on phones without a PIN, pattern, or password. @@ -277,7 +289,10 @@ screen exists, with keyed requests superseding stale loads. Capture state reaches the UI as an immutable `CaptureSnapshot` (phase, latest typed and localized notice, captured targets, one-time checklist) published by `CaptureStatus` listeners on the main looper. Nothing in the UI parses status -text or polls. +text or polls. Backup export and restore, CSV import and undo, and profile +region edits and deletion run through `CaptureGuard`: after one confirmation it +requests the stop and continues only when a listener observes `STOPPED`, or +explains after 20 seconds that capture is still stopping and changes nothing. `PrimaryNavigation` provides the Home, Platoon, and Weekly bar used by `Screen`. It launches existing Activities and owns no data or domain state. Onboarding @@ -323,7 +338,10 @@ the repository's exclusive database gate. Full profile deletion uses a durable pending-deletion set: selector metadata is removed first, then the scoped SQLite database, retained evidence, member ordering, weekly cutlines, and reset setting are deleted. If the process stops mid-cleanup, registry initialization resumes -the remaining bounded deletion before showing profiles. +the remaining bounded deletion before showing profiles. A queued scope is never +handed to a newly detected or declared profile, which takes the random +fallback ID instead, and a queued scope that the registry lists again after a +committed restore leaves the queue rather than being deleted. The design deliberately favors composition over deep inheritance. Abstraction and polymorphism appear at real variation points (`GameData`, `ParseEvent`, and @@ -380,10 +398,12 @@ profile on an installation that does not yet have any registered profiles. Complete restore validates the filename, archive entries and identity, checksums, settings completeness and ranges, current database schema, SQLite -integrity, and foreign keys before mutation. Database replacement runs under -the repository maintenance lock. The previous database and a settings snapshot -are retained until both resources commit, so any failure restores the prior -state instead of leaving a partial import. A fresh install records that no +integrity, and foreign keys before mutation. Database replacement, and the +rollback of a failed replacement, run under one hold of the repository +maintenance lock. The previous database, together with any WAL or rollback +journal a crash left beside it, and a settings snapshot are retained until both +resources commit, so any failure restores the prior state instead of leaving a +partial import. A fresh install records that no database existed so rollback removes the staged replacement rather than mistaking it for prior state. Cleanup deletes the transaction state file first, so a process death during cleanup leaves only leftovers to delete @@ -413,7 +433,13 @@ database under the repository's exclusive maintenance lock. Durable restore state resumes interrupted quarantine/database replacement after process death; ordinary failures restore both the evidence and the preceding undo checkpoint. A post-import database digest refuses undo after any later Platoon mutation so -a one-level rollback cannot silently overwrite newer work. +a one-level rollback cannot silently overwrite newer work; the digest is +compared again inside the exclusive section that installs the checkpoint, so an +ingest that lands after the first check is never overwritten. Interrupted +imports and undos are recovered for every registered Platoon at process start, +off the main thread, and again before capture ingests anything, so a late +recovery cannot replace newer captured evidence. A recovery that fails is logged +and its durable state left for the next attempt instead of crashing the launch. Weekly PNG export builds a privacy projection rather than screenshotting the Activity. Names are included by default; UIDs and private notes are opt-in. @@ -484,7 +510,10 @@ bounds whose displayed minimum remains below the configured threshold. Unknown values and lower bounds that already meet the threshold remain neutral. Completed roster captures are written under a non-importable temporary suffix -and atomically published as `.csv` only after protocol completion. Retained CSV +and atomically published as `.csv` only after protocol completion. A working +file whose write or publication fails is deleted, and capture start removes any +left by process death. Member and name bounds apply to the UID-deduplicated +completed roster, so a discarded partial roster never reports a save failure. Retained CSV reconciliation runs off the UI thread, skips represented source identities, and imports older evidence without rewriting current membership. Both backup exports reconcile completed retained evidence before taking their locked diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index c2d8d80..56a5d7f 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -24,7 +24,9 @@ rules below take precedence over stale copied dates in archived templates. - Gunsmoke scoring closes at 02:00 on the final Sunday. A Saturday snapshot at or after 02:00 therefore confirms the final cumulative Gunsmoke score, while merit, login, Daily Patrol, and the Saturday game day remain open until - 05:00. A daily Saturday score delta still requires a trustworthy opening + 05:00. A capture exactly at a 05:00 reset records the closed previous game + day, so the Sunday 05:00 capture is the final, closing Saturday capture of + the event week. A daily Saturday score delta still requires a trustworthy opening boundary; the app does not invent one from the final total alone. - A capture shortly before 05:00 is not an exact opening boundary for the next day. Its following counter delta can include the previous day's tail, so the @@ -86,7 +88,10 @@ additional upper bound: when an aggregate saturates that bound, it can also fix the attempt count and the otherwise ambiguous per-attempt rounding merit. Counter resets are handled by treating a negative delta as the current counter -value. Daily activity is derived from Total Merit because Merit This Week +value. A roster comparison across a Monday 05:00 weekly reset or a Gunsmoke +event start reports the newer Merit This Week or Gunsmoke score as a lower +bound (`≥`) even when it came back higher, because the older counter's gain +before the reset is unknown. Daily activity is derived from Total Merit because Merit This Week resets on Monday while a Gunsmoke week begins on Sunday. A capped pre-Gunsmoke counter is a valid opening anchor only inside the immediately preceding Monday 05:00-to-Sunday 05:00 calendar interval in the @@ -105,7 +110,10 @@ transitions. - Non-Gunsmoke weeks constrain every `0/50/90` daily allocation against all captured Weekly Merit checkpoints. A 21917 recent-login time proves the member logged in on its 05:00-based game day and narrows that day to 50 or - 90; an exact Daily Patrol event fixes it at 90. + 90; an exact Daily Patrol event fixes it at 90. A Standard day never shows + more than 90: a delta above the cap after a near-reset baseline contains the + previous day's login or patrol tail, so the day keeps a value only when a + single split of that delta remains. - Values shared by every compatible allocation are exact. Ambiguous completed days remain unknown instead of displaying an arbitrary allocation, while an unfinished day displays only its confirmed positive lower bound until it @@ -128,9 +136,17 @@ transitions. merit, attendance, Daily Patrol, and the week transition; unresolved values therefore remain lower bounds. - If a later transition exceeds the bounded detailed search, already solved - earlier days remain visible. The cumulative score/high-score attempt floor - and timestamped Login or Daily Patrol facts are retained even when a broader - aggregate fallback admits a smaller value. + earlier days remain visible and the rest of the run continues with interval + bounds: each capture is tracked only by its attempt count, Login, and Daily + Patrol, with merit and score ranges for how an interval can split between + the previous day's last minutes and the next day. These bounds admit every + split the detailed search would, so later days keep defensible lower bounds + and shared Login / Daily Patrol states instead of being dropped. A capture + shortly before 05:00 every day therefore keeps each day as a lower bound + (its interval may still belong to the previous day) while the final event + packet can still make the weekly totals exact. The cumulative + score/high-score attempt floor and timestamped Login or Daily Patrol facts + are retained even when a broader aggregate fallback admits a smaller value. - The separate Join/Withdraw section combines UID-safe 21917 roster changes, exact 21960 Updates timestamps, and manually entered membership history. Snapshot-only boundaries are labelled with their observation time instead of @@ -189,7 +205,11 @@ transitions. delete the member instead when the complete record should be removed. - The current member list displays the latest open membership period. A withdrawn member displays the latest join and withdrawal times. -- Snapshot differences provide an observed time window. Exact in-game Updates +- Snapshot differences provide an observed time window. A name-resolved 21935 + activity can date an inferred join or withdrawal only when it lies after the + previous roster and no later than the roster that revealed the change, at + most 12 hours before it; an activity after that roster, which already showed + the change, never becomes the boundary. Exact in-game Updates events can replace an inferred boundary only when a unique 21917 UID can be correlated safely; 21935 contains names but no UID. - Exact packet-linked boundary fields are immutable while editing. Users can @@ -227,6 +247,9 @@ transitions. - 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. + Revisions are ordered, deduplicated, and trimmed by recording order rather + than by their wall-clock time, so a revision recorded after the device clock + moved backwards is still the latest one instead of being trimmed at once. - A manual Daily Patrol requires manual attendance. When an edit sets one of the two and the other stored choice contradicts it, the edited choice wins (Patrol yes also sets Login yes; Login no also sets Patrol no); Login no with diff --git a/protocol/build.gradle b/protocol/build.gradle index 67dab5b..d8a32c9 100644 --- a/protocol/build.gradle +++ b/protocol/build.gradle @@ -1,22 +1,17 @@ +// Pure Kotlin: the protocol decoder has no Android dependencies, so it builds and tests on the JVM +// without the Android library variants, lint models, and AAR packaging. plugins { - id 'com.android.library' + id 'org.jetbrains.kotlin.jvm' } -android { - namespace = 'dev.gf2log.protocol' - compileSdk = 36 - - defaultConfig { - minSdk = 26 - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} - testOptions { - unitTests.returnDefaultValues = true +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/protocol/src/main/AndroidManifest.xml b/protocol/src/main/AndroidManifest.xml deleted file mode 100644 index 8072ee0..0000000 --- a/protocol/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - -