From 9053d20b20c2363705bd1b91a88ad1ed1e3027f6 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 6 Nov 2026 23:55:24 +0900 Subject: [PATCH 01/98] Keep webhook tokens out of failures and report storage errors honestly Parse failures no longer quote the pasted URL, background failures log only the exception type, the PTB, Canary and discordapp.com hosts are stored as discord.com, and a Keystore or storage failure no longer claims the address is invalid. Findings: sec-egress-secrets-1, q-uiux-11, q-uiux-m1 --- .../java/dev/gf2log/app/DiscordWebhookRow.kt | 22 +++++++-- .../app/discord/DiscordWebhookPolicy.kt | 38 +++++++++++++-- .../java/dev/gf2log/app/ui/BackgroundWork.kt | 3 +- app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../dev/gf2log/app/DiscordWebhookSaveTest.kt | 48 +++++++++++++++++++ .../app/discord/DiscordWebhookPolicyTest.kt | 43 +++++++++++++++++ 7 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/DiscordWebhookSaveTest.kt diff --git a/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt b/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt index ef8363b..b8d8b22 100644 --- a/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt +++ b/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt @@ -3,6 +3,7 @@ package dev.gf2log.app import android.app.Activity import android.app.AlertDialog import android.text.InputType +import dev.gf2log.app.discord.DiscordWebhookPolicy import dev.gf2log.app.discord.DiscordWebhookSecretStore import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs @@ -61,13 +62,17 @@ internal class DiscordWebhookRow(private val activity: Activity, private val wor dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { val value = input.text.toString() if (work.isBusy(WRITE)) return@setOnClickListener - work.run(key = WRITE, task = { DiscordWebhookSecretStore(this).save(value) }) { result -> - if (result.isSuccess) { + work.run( + key = WRITE, + task = { discordWebhookSaveFailure(value) { DiscordWebhookSecretStore(this).save(it) } }, + ) { result -> + val failure = result.getOrElse { R.string.discord_webhook_store_failed } + if (failure == null) { dialog.dismiss() Feedback.show(this, R.string.discord_webhook_saved) refresh() } else { - error.showMessage(getString(R.string.discord_webhook_invalid)) + error.showMessage(getString(failure)) } } } @@ -102,3 +107,14 @@ internal class DiscordWebhookRow(private val activity: Activity, private val wor const val WRITE = "webhook" } } + +/** + * Saves [value] through [store] and returns the message for a failure, or null once it is stored. + * The address is validated on its own first, so only an address the policy rejects is reported as + * invalid; a Keystore or storage failure never sends the user to re-paste a correct URL. + */ +internal fun discordWebhookSaveFailure(value: String, store: (String) -> Unit): Int? { + val canonical = runCatching { DiscordWebhookPolicy.requireValid(value).toASCIIString() } + .getOrElse { return R.string.discord_webhook_invalid } + return runCatching { store(canonical) }.fold(onSuccess = { null }, onFailure = { R.string.discord_webhook_store_failed }) +} diff --git a/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt index c6768d0..d4d14e8 100644 --- a/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt @@ -1,15 +1,17 @@ package dev.gf2log.app.discord import java.net.URI +import java.net.URISyntaxException +import java.util.Locale /** Strictly limits stored credentials to Discord's incoming-webhook execution endpoint. */ object DiscordWebhookPolicy { fun requireValid(value: String): URI { val normalized = value.trim() - require(normalized.length in 1..MAX_URL_CHARS) { "Invalid Discord webhook URL" } - val uri = URI(normalized) + require(normalized.length in 1..MAX_URL_CHARS) { INVALID_URL } + val uri = canonicalHost(parse(normalized)) require(uri.scheme.equals("https", ignoreCase = true)) { "Discord webhook must use HTTPS" } - require(uri.host.equals("discord.com", ignoreCase = true)) { + require(uri.host.equals(CANONICAL_HOST, ignoreCase = true)) { "Discord webhook host must be discord.com" } require(uri.port == -1 || uri.port == 443) { "Discord webhook must use the HTTPS port" } @@ -21,6 +23,36 @@ object DiscordWebhookPolicy { return uri } + /** + * URISyntaxException quotes its whole input, token included, and failures are logged; the + * replacement carries neither the input nor a cause. + */ + private fun parse(value: String): URI = try { + URI(value) + } catch (_: URISyntaxException) { + throw IllegalArgumentException(INVALID_URL) + } + + /** + * The PTB, Canary, and legacy hosts copy the same webhook under another name. Only the host + * is replaced, so the stored and sent host is always discord.com and every other component + * still meets the checks above unchanged. A URL with credentials keeps its host and fails. + */ + private fun canonicalHost(uri: URI): URI { + val host = uri.host?.lowercase(Locale.ROOT) ?: return uri + if (host !in ALIAS_HOSTS || uri.rawUserInfo != null) return uri + return parse(buildString { + append(uri.scheme).append("://").append(CANONICAL_HOST) + if (uri.port != -1) append(':').append(uri.port) + append(uri.rawPath.orEmpty()) + uri.rawQuery?.let { append('?').append(it) } + uri.rawFragment?.let { append('#').append(it) } + }) + } + + private const val INVALID_URL = "Invalid Discord webhook URL" + private const val CANONICAL_HOST = "discord.com" + private val ALIAS_HOSTS = setOf("ptb.discord.com", "canary.discord.com", "discordapp.com") private const val MAX_URL_CHARS = 512 private val WEBHOOK_PATH = Regex( "^/api(?:/v\\d{1,2})?/webhooks/\\d{17,20}/[A-Za-z0-9._-]{32,200}$", diff --git a/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt b/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt index 0ffd672..6bb9dae 100644 --- a/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt +++ b/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt @@ -49,7 +49,8 @@ internal class BackgroundWork(owner: LifecycleOwner, threadName: String) { key?.let { running[it] = (running[it] ?: 0) + 1 } executor.execute { val result = runCatching(task) - result.exceptionOrNull()?.let { Log.w(TAG, "Background task failed", it) } + // Only the type: a message can quote user input, such as a pasted webhook token. + result.exceptionOrNull()?.let { Log.w(TAG, "Background task failed: ${it.javaClass.name}") } main.post { key?.let { running[it] = ((running[it] ?: 1) - 1).coerceAtLeast(0) } if (destroyed) return@post diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fb3f4ec..2c6c218 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -349,6 +349,7 @@ 저장된 웹훅 제거 Discord 웹훅을 안전하게 저장했습니다. discord.com의 유효한 HTTPS 수신 웹훅 URL을 입력하세요. + 이 기기에 웹훅을 안전하게 저장할 수 없습니다. 다시 시도하세요. 저장된 Discord 웹훅을 제거할까요? CSV를 보내려면 다시 붙여 넣어야 합니다. 저장된 Discord 웹훅을 제거했습니다. 저장된 Discord 웹훅을 제거할 수 없습니다. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef8188d..5c2fd19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -353,6 +353,7 @@ Remove saved webhook Discord webhook saved securely. Enter a valid HTTPS incoming-webhook URL on discord.com. + Unable to store the webhook securely on this device. Try again. 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. diff --git a/app/src/test/java/dev/gf2log/app/DiscordWebhookSaveTest.kt b/app/src/test/java/dev/gf2log/app/DiscordWebhookSaveTest.kt new file mode 100644 index 0000000..e308491 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/DiscordWebhookSaveTest.kt @@ -0,0 +1,48 @@ +package dev.gf2log.app + +import java.security.GeneralSecurityException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class DiscordWebhookSaveTest { + private val valid = "https://discord.com/api/webhooks/12345678901234567/" + "a".repeat(64) + + @Test + fun aRejectedAddressIsReportedAsInvalidWithoutTouchingTheStore() { + var stored = false + val message = discordWebhookSaveFailure(valid.replace("https", "http")) { stored = true } + + assertEquals(R.string.discord_webhook_invalid, message) + assertFalse(stored) + } + + @Test + fun aKeystoreOrStorageFailureIsNotBlamedOnTheAddress() { + assertEquals( + R.string.discord_webhook_store_failed, + discordWebhookSaveFailure(valid) { throw GeneralSecurityException("cipher unavailable") }, + ) + assertEquals( + R.string.discord_webhook_store_failed, + discordWebhookSaveFailure(valid) { check(false) { "Unable to store the encrypted Discord webhook" } }, + ) + // Once the address passed, even a Keystore IllegalArgumentException is a storage failure. + assertEquals( + R.string.discord_webhook_store_failed, + discordWebhookSaveFailure(valid) { throw IllegalArgumentException("bad key spec") }, + ) + } + + @Test + fun theStoreReceivesTheCanonicalAddress() { + var stored: String? = null + val message = discordWebhookSaveFailure(" https://ptb.discord.com/api/webhooks/12345678901234567/${"a".repeat(64)} ") { + stored = it + } + + assertNull(message) + assertEquals(valid, stored) + } +} diff --git a/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt b/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt index b1b5eab..f1baa99 100644 --- a/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt @@ -1,6 +1,8 @@ package dev.gf2log.app.discord import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -26,6 +28,47 @@ class DiscordWebhookPolicyTest { } } + @Test + fun unparsableInputNeverQuotesTheTokenInTheFailure() { + val token = "A".repeat(36) + val url = "https://discord.com/api/webhooks/123456789012345678/$token" + listOf("$url x", "<$url>", "$url|extra", "https://discord.com/api/webhooks/123456789012345678/A|$token").forEach { value -> + val error = runCatching { DiscordWebhookPolicy.requireValid(value) }.exceptionOrNull() + assertTrue(value, error is IllegalArgumentException) + assertEquals("Invalid Discord webhook URL", error!!.message) + assertNull(error.cause) + assertTrue(error.suppressed.isEmpty()) + generateSequence(error, Throwable::cause).forEach { link -> + assertFalse(link.message.orEmpty().contains(token)) + assertFalse(link.toString().contains(token)) + } + } + } + + @Test + fun officialAliasHostsAreStoredAsDiscordCom() { + val path = "/api/webhooks/12345678901234567/" + "b".repeat(40) + listOf("ptb.discord.com", "canary.discord.com", "discordapp.com", "PTB.Discord.com").forEach { host -> + assertEquals(host, "https://discord.com$path", DiscordWebhookPolicy.requireValid("https://$host$path").toASCIIString()) + } + assertEquals( + "https://discord.com:443$path", + DiscordWebhookPolicy.requireValid("https://canary.discord.com:443$path").toASCIIString(), + ) + listOf( + "https://evil.ptb.discord.com$path", + "https://discord.com.evil.net$path", + "https://ptb.discord.com:8443$path", + "https://user@ptb.discord.com$path", + "http://ptb.discord.com$path", + "https://discordapp.com$path?wait=true", + "https://canary.discord.com/api/webhooks/%3112345678901234567/" + "b".repeat(40), + "https://xdiscordapp.com$path", + ).forEach { value -> + assertTrue(value, runCatching { DiscordWebhookPolicy.requireValid(value) }.isFailure) + } + } + @Test fun extractsOnlyCsvBodyFromValidatedHistoryEnvelope() { val content = """ From 248573890644a68b6302ceef0b5af004d4203064 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 6 Nov 2026 23:56:27 +0900 Subject: [PATCH 02/98] Compare the typed Platoon name in its typeable form before deleting Both sides are compared after NFC, whitespace collapsing and removal of control, format and bidi characters and unpaired surrogates; suggestions are off for the field, and a name with nothing typeable is confirmed by its ID. Findings: q-input-9, sec-protocol-3 --- .../dev/gf2log/app/PlatoonProfileSelector.kt | 62 +++++++++++++++++-- app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../gf2log/app/PlatoonNameConfirmationTest.kt | 54 ++++++++++++++++ 4 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/PlatoonNameConfirmationTest.kt diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index 5c6e2c2..ab06413 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -238,10 +238,23 @@ internal object PlatoonProfileSelector { } private fun confirmDeleteByName(activity: Activity, profile: PlatoonProfile) { - val input = activity.textField(activity.getString(R.string.type_platoon_name), inputType = InputType.TYPE_CLASS_TEXT) + val typeable = PlatoonNameConfirmation.typeable(profile.platoonName) + val input = activity.textField( + activity.getString(if (typeable.isEmpty()) R.string.type_platoon_id else R.string.type_platoon_name), + // Suggestions would append a space or swap the spelling of a name that must match exactly. + inputType = if (typeable.isEmpty()) { + InputType.TYPE_CLASS_NUMBER + } else { + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + }, + ) val content = Dialogs.content(activity) { addView(activity.text( - activity.getString(R.string.delete_platoon_name_warning, profile.platoonName), + if (typeable.isEmpty()) { + activity.getString(R.string.delete_platoon_id_warning, profile.platoonId) + } else { + activity.getString(R.string.delete_platoon_name_warning, typeable) + }, TextRole.SECONDARY, ), activity.lp()) addView(input, activity.lp(top = 12)) @@ -257,7 +270,9 @@ internal object PlatoonProfileSelector { val delete = dialog.getButton(AlertDialog.BUTTON_POSITIVE) delete.isEnabled = false delete.setOnClickListener { - if (input.text.toString() != profile.platoonName) return@setOnClickListener + if (!PlatoonNameConfirmation.matches(input.text.toString(), profile.platoonName, profile.platoonId)) { + return@setOnClickListener + } dialog.dismiss() runMaintenance(activity, R.string.platoon_delete_failed) { check(PlatoonProfileAdministration(activity).deleteProfile(profile.storageId)) { @@ -265,7 +280,9 @@ internal object PlatoonProfileSelector { } } } - input.onTextChanged { delete.isEnabled = it == profile.platoonName } + input.onTextChanged { + delete.isEnabled = PlatoonNameConfirmation.matches(it, profile.platoonName, profile.platoonId) + } } } @@ -326,3 +343,40 @@ internal object PlatoonProfileSelector { Thread(runnable, "GF2ProfileMaintenance") } } + +/** + * The type-the-name delete confirmation compares the visible, typeable form of both sides, so a + * keyboard's trailing space, a decomposed Hangul spelling, or an invisible format or bidi control + * in a captured name can never make the confirmation impossible to satisfy. + */ +internal object PlatoonNameConfirmation { + /** + * NFC text with whitespace runs collapsed, controls, format characters (zero-width and bidi + * marks), and unpaired surrogates removed, and the ends trimmed; empty when nothing is typeable. + */ + fun typeable(value: String): String { + val text = java.text.Normalizer.normalize(value.replace(WHITESPACE, " "), java.text.Normalizer.Form.NFC) + val kept = StringBuilder(text.length) + var index = 0 + while (index < text.length) { + val codePoint = text.codePointAt(index) + val width = Character.charCount(codePoint) + val unpaired = width == 1 && Character.isSurrogate(text[index]) + val type = Character.getType(codePoint) + if (!unpaired && type != Character.FORMAT.toInt() && type != Character.CONTROL.toInt()) { + kept.appendCodePoint(codePoint) + } + index += width + } + return kept.toString().replace(WHITESPACE, " ").trim() + } + + /** A name with no typeable characters is confirmed by its Platoon ID instead. */ + fun matches(typed: String, platoonName: String, platoonId: Long): Boolean { + val expected = typeable(platoonName) + if (expected.isEmpty()) return typed.trim() == platoonId.toString() + return typeable(typed) == expected + } + + private val WHITESPACE = Regex("\\s+") +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 2c6c218..bd32cd9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -184,7 +184,9 @@ %1$s 삭제 %1$s 서클을 삭제하면 멤버, 주간 표, 설정, 체크포인트 및 보관된 근거 자료가 영구적으로 제거됩니다. 복구할 수 없습니다. 삭제하려면 서클 이름을 정확하게 입력하세요: %1$s + 삭제하려면 서클 ID를 정확하게 입력하세요: %1$d 정확한 서클 이름 입력 + 서클 ID 입력 서클 삭제를 완료할 수 없습니다. 안전하게 다시 정리합니다. %1$s · 이 기기의 다음 초기화 %2$s (%3$s) 수동 · %1$s (기기: %2$s) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c2fd19..bec9adc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -184,7 +184,9 @@ Delete %1$s Deleting %1$s permanently removes its members, weekly tables, settings, checkpoints, and retained evidence. This cannot be undone. To delete this Platoon, enter its name exactly: %1$s + To delete this Platoon, enter its ID exactly: %1$d Enter the exact Platoon name + Enter the Platoon ID Unable to finish deleting the Platoon. Cleanup will be retried safely. %1$s · next reset %2$s on this phone (%3$s) Manual · %1$s (phone: %2$s) diff --git a/app/src/test/java/dev/gf2log/app/PlatoonNameConfirmationTest.kt b/app/src/test/java/dev/gf2log/app/PlatoonNameConfirmationTest.kt new file mode 100644 index 0000000..6089a74 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/PlatoonNameConfirmationTest.kt @@ -0,0 +1,54 @@ +package dev.gf2log.app + +import java.text.Normalizer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlatoonNameConfirmationTest { + @Test + fun keyboardSpacingDoesNotBlockTheConfirmation() { + listOf("MyPlatoon ", " MyPlatoon", "MyPlatoon\n").forEach { typed -> + assertTrue(typed, PlatoonNameConfirmation.matches(typed, "MyPlatoon", 42)) + } + assertTrue(PlatoonNameConfirmation.matches("My Platoon", "My Platoon", 42)) + } + + @Test + fun composedAndDecomposedSpellingsMatch() { + val composed = "서클" + val decomposed = Normalizer.normalize(composed, Normalizer.Form.NFD) + + assertTrue(composed != decomposed) + assertTrue(PlatoonNameConfirmation.matches(composed, decomposed, 42)) + assertTrue(PlatoonNameConfirmation.matches(decomposed, composed, 42)) + } + + @Test + fun invisibleFormatAndBidiCharactersAreNotRequired() { + assertTrue(PlatoonNameConfirmation.matches("AB", "A​B", 42)) + assertTrue(PlatoonNameConfirmation.matches("abc", "‮abc", 42)) + assertTrue(PlatoonNameConfirmation.matches("abc", "⁦abc⁩", 42)) + assertTrue(PlatoonNameConfirmation.matches("ab", "a\uD800b", 42)) + assertEquals("abc", PlatoonNameConfirmation.typeable("‮abc")) + // A paired surrogate is a real character and is kept. + assertEquals("a😀", PlatoonNameConfirmation.typeable("a😀")) + } + + @Test + fun aDifferentNameIsStillRejected() { + assertFalse(PlatoonNameConfirmation.matches("MyPlatoon2", "MyPlatoon", 42)) + assertFalse(PlatoonNameConfirmation.matches("myplatoon", "MyPlatoon", 42)) + assertFalse(PlatoonNameConfirmation.matches("", "MyPlatoon", 42)) + assertFalse(PlatoonNameConfirmation.matches("42", "MyPlatoon", 42)) + } + + @Test + fun aNameWithNothingTypeableIsConfirmedByItsId() { + assertEquals("", PlatoonNameConfirmation.typeable("​‮")) + assertFalse(PlatoonNameConfirmation.matches("", "​‮", 42)) + assertFalse(PlatoonNameConfirmation.matches("​‮", "​‮", 42)) + assertTrue(PlatoonNameConfirmation.matches(" 42 ", "​‮", 42)) + } +} From 3a875b5f29c1555531c4f67a25f91ea1fdda4517 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 6 Nov 2026 23:57:52 +0900 Subject: [PATCH 03/98] Close TCP and ICMP flows whose socket cannot be opened A refused socket only set CONN_STATUS_SOCKET_ERROR, so zdtun_conn_close returned early and on_connection_close never ran: the flow state leaked, Java never saw onFlowClosed, and the client got no RST. Close through zdtun_conn_close instead, with the RST acknowledging the SYN. Fixes sec-native-forward-path-1. --- app/src/main/cpp/third_party/zdtun/zdtun.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/cpp/third_party/zdtun/zdtun.c b/app/src/main/cpp/third_party/zdtun/zdtun.c index e2632ff..ad6a25e 100644 --- a/app/src/main/cpp/third_party/zdtun/zdtun.c +++ b/app/src/main/cpp/third_party/zdtun/zdtun.c @@ -1172,7 +1172,10 @@ static int handle_tcp_fwd(zdtun_t *tun, const zdtun_pkt_t *pkt, if(tcp_sock == INVALID_SOCKET) { error("Cannot create TCP socket[%d]", socket_errno); - conn->status = CONN_STATUS_SOCKET_ERROR; + // Close through zdtun_conn_close so on_connection_close runs for the user data, and the + // client gets an RST that acknowledges its SYN instead of retrying until it times out. + conn->tcp.client_seq = ntohl(data->th_seq) + 1; + zdtun_conn_close(tun, conn, CONN_STATUS_SOCKET_ERROR); return -1; } @@ -1572,7 +1575,7 @@ static int handle_icmp_fwd(zdtun_t *tun, const zdtun_pkt_t *pkt, zdtun_conn_t *c if(icmp_sock == INVALID_SOCKET) { error("Cannot create ICMP socket[%d]", socket_errno); - conn->status = CONN_STATUS_SOCKET_ERROR; + zdtun_conn_close(tun, conn, CONN_STATUS_SOCKET_ERROR); return -1; } From 1c3f8bf302d68dee8ad81c2d3b30bfced7d07c82 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 6 Nov 2026 23:58:01 +0900 Subject: [PATCH 04/98] Purge datagram and web flows before inspected TCP at the socket cap The forced purge destroyed the oldest connections whatever their protocol, so a burst of other in-VPN sockets could RST the game's quiet capture connection. Rank connections instead: socketless ones first and uncounted, then UDP/ICMP, then TCP, and last the flows gf2capture still inspects (new optional on_purge_priority callback). Fixes sec-native-forward-path-2. --- app/src/main/cpp/gf2capture.c | 10 ++++++ app/src/main/cpp/third_party/zdtun/zdtun.c | 40 +++++++++++++++++++--- app/src/main/cpp/third_party/zdtun/zdtun.h | 11 ++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/app/src/main/cpp/gf2capture.c b/app/src/main/cpp/gf2capture.c index 15795e1..dfe09ae 100644 --- a/app/src/main/cpp/gf2capture.c +++ b/app/src/main/cpp/gf2capture.c @@ -360,6 +360,15 @@ static void connection_close_callback(zdtun_t *tunnel, const zdtun_conn_t *conne free(flow); } +// When the socket cap forces zdtun to purge, flows that may still carry game frames go only after +// every UDP, ICMP and web flow, so a burst of other traffic in the VPN cannot RST the game's +// connection and end its capture early. +static int purge_priority_callback(zdtun_t *tunnel, const zdtun_conn_t *connection) { + (void) tunnel; + const flow_state_t *flow = get_flow_state(connection); + return flow != NULL && flow->inspect_payload ? 1 : 0; +} + static void process_outgoing_packet( capture_context_t *context, zdtun_t *tunnel, @@ -452,6 +461,7 @@ static void *capture_thread_main(void *argument) { .on_socket_open = protect_socket_callback, .on_connection_open = connection_open_callback, .on_connection_close = connection_close_callback, + .on_purge_priority = purge_priority_callback, }; zdtun_t *tunnel = zdtun_init(&callbacks, context); if (tunnel == NULL) { diff --git a/app/src/main/cpp/third_party/zdtun/zdtun.c b/app/src/main/cpp/third_party/zdtun/zdtun.c index ad6a25e..1a46507 100644 --- a/app/src/main/cpp/third_party/zdtun/zdtun.c +++ b/app/src/main/cpp/third_party/zdtun/zdtun.c @@ -106,6 +106,7 @@ typedef struct zdtun_conn { proxy_mode_t proxy_mode; socks5_status_t socks5_status; uint8_t socks5_skip; + uint8_t purge_rank; // see zdtun_purge_expired union { struct { @@ -1970,9 +1971,30 @@ int zdtun_handle_fd(zdtun_t *tun, const fd_set *rd_fds, const fd_set *wr_fds) { /* ******************************************************* */ +// Force purge order, cheapest loss first. Connections without a socket free nothing the cap +// counts, so they go first and are not counted; UDP and ICMP are cheap to reopen; then TCP; +// the connections on_purge_priority asks to keep go last. +#define PURGE_RANK_NO_SOCKET 0 +#define PURGE_RANK_DATAGRAM 1 +#define PURGE_RANK_TCP 2 +#define PURGE_RANK_KEEP 3 + +static uint8_t purge_rank(zdtun_t *tun, const zdtun_conn_t *conn) { + if((conn->status >= CONN_STATUS_CLOSED) || (conn->sock == INVALID_SOCKET)) + return PURGE_RANK_NO_SOCKET; + + if(tun->callbacks.on_purge_priority && (tun->callbacks.on_purge_priority(tun, conn) != 0)) + return PURGE_RANK_KEEP; + + return (conn->tuple.ipproto == IPPROTO_TCP) ? PURGE_RANK_TCP : PURGE_RANK_DATAGRAM; +} + // negative, zero, or positive <=> A before, equal to, or after B -static inline int zdtun_conn_cmp_timestamp_asc(zdtun_conn_t *a, zdtun_conn_t *b) { - return(a->tstamp - b->tstamp); +static inline int zdtun_conn_cmp_purge_order(zdtun_conn_t *a, zdtun_conn_t *b) { + if(a->purge_rank != b->purge_rank) + return((int)a->purge_rank - (int)b->purge_rank); + + return((a->tstamp > b->tstamp) - (a->tstamp < b->tstamp)); } // purges old connections. Harvests the closed connections (set by close_conn) @@ -2008,14 +2030,22 @@ void zdtun_purge_expired(zdtun_t *tun) { debug("FORCE PURGE %d items", to_purge); - HASH_SORT(tun->conn_table, zdtun_conn_cmp_timestamp_asc); + HASH_ITER(hh, tun->conn_table, conn, tmp) { + conn->purge_rank = purge_rank(tun, conn); + } + + HASH_SORT(tun->conn_table, zdtun_conn_cmp_purge_order); HASH_ITER(hh, tun->conn_table, conn, tmp) { - if(to_purge == 0) + if(to_purge <= 0) break; + uint8_t frees_socket = (conn->purge_rank != PURGE_RANK_NO_SOCKET); + destroy_conn(tun, conn); - to_purge--; + + if(frees_socket) + to_purge--; } } } diff --git a/app/src/main/cpp/third_party/zdtun/zdtun.h b/app/src/main/cpp/third_party/zdtun/zdtun.h index 078a206..bcd540c 100644 --- a/app/src/main/cpp/third_party/zdtun/zdtun.h +++ b/app/src/main/cpp/third_party/zdtun/zdtun.h @@ -320,6 +320,17 @@ typedef struct zdtun_callbacks { * @param conn_info information about the connection. User provided user_data should be manually freed. */ void (*on_connection_close) (zdtun_t *tun, const zdtun_conn_t *conn_info); + + /* + * @brief (optional) Called when the open sockets reach the cap and the oldest connections must + * be purged, for each connection that holds a socket. + * + * @param tun the zdtun instance + * @param conn_info information about the connection + * + * @return != 0 to purge this connection only after every UDP, ICMP and other TCP connection + */ + int (*on_purge_priority) (zdtun_t *tun, const zdtun_conn_t *conn_info); } zdtun_callbacks_t; /* From 2ea08ffa6bfbebe933e2e04c2398f2cb15582cc4 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 6 Nov 2026 23:58:01 +0900 Subject: [PATCH 05/98] Read the DNS QR bit in host byte order Fixes sec-native-forward-path-3. --- app/src/main/cpp/third_party/zdtun/zdtun.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/cpp/third_party/zdtun/zdtun.c b/app/src/main/cpp/third_party/zdtun/zdtun.c index 1a46507..88b522a 100644 --- a/app/src/main/cpp/third_party/zdtun/zdtun.c +++ b/app/src/main/cpp/third_party/zdtun/zdtun.c @@ -835,7 +835,8 @@ static void check_dns_request(zdtun_conn_t *conn, char *l4_payload, uint16_t l4_ dns = (struct dns_packet*)l4_payload; - if((dns->flags & DNS_FLAGS_MASK) == DNS_TYPE_REQUEST) + // flags is in network byte order; the mask selects the QR bit + if((ntohs(dns->flags) & DNS_FLAGS_MASK) == DNS_TYPE_REQUEST) conn->dns.pending_queries++; } @@ -850,7 +851,7 @@ static int check_dns_purge(zdtun_t *tun, zdtun_conn_t *conn, dns = (struct dns_packet*)l4_payload; - if(((dns->flags & DNS_FLAGS_MASK) == DNS_TYPE_RESPONSE) + if(((ntohs(dns->flags) & DNS_FLAGS_MASK) == DNS_TYPE_RESPONSE) && (conn->dns.pending_queries > 0)) { conn->dns.pending_queries--; From c30544da9c41fea8f5a73dbfacc987d10319a8e4 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 6 Nov 2026 23:58:01 +0900 Subject: [PATCH 06/98] Forward the client FIN only after the bytes before it A FIN+ACK half-closed the server socket at once, dropped the FIN segment's own payload and ACKed bytes still queued, which later failed with EPIPE and reset the connection. Queue the payload and defer the shutdown and its ACK until the queue drains; if the server closes first, ACK the pending FIN so the client can finish. A FIN retransmitted after it was ACKed is ACKed again without being counted twice. Fixes sec-native-forward-path-4. --- app/src/main/cpp/third_party/zdtun/zdtun.c | 52 +++++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/app/src/main/cpp/third_party/zdtun/zdtun.c b/app/src/main/cpp/third_party/zdtun/zdtun.c index 88b522a..d2001d3 100644 --- a/app/src/main/cpp/third_party/zdtun/zdtun.c +++ b/app/src/main/cpp/third_party/zdtun/zdtun.c @@ -121,6 +121,7 @@ typedef struct zdtun_conn { struct { uint8_t fin_ack_sent:1; uint8_t client_closed:1; + uint8_t client_fin_pending:1; // client FIN waits for tx_queue to reach the server }; } tcp; }; @@ -1299,6 +1300,29 @@ static int handle_tcp_fwd(zdtun_t *tun, const zdtun_pkt_t *pkt, debug("Got TCP FIN+ACK from client"); + if(conn->tcp.client_closed) { + // A retransmitted FIN. While it waits for the queue it is not ACKed yet; once forwarded, + // ACK it again without counting it twice. + if(conn->tcp.client_fin_pending) + return 0; + + build_reply_tcpip(tun, conn, TH_ACK, 0, 0); + return send_to_client(tun, conn, TCP_HEADER_LEN); + } + + if((conn->sock != INVALID_SOCKET) && ((pkt->l7_len > 0) || conn->tcp.tx_queue)) { + // The FIN follows bytes the server has not received yet: queue its own payload (pushed, + // since nothing follows it) and let handle_queued_tcp_data forward the FIN and ACK it + // once everything before it has been sent. + if((pkt->l7_len > 0) && + (enqueue_tcp_data(tun, conn, pkt->l7, pkt->l7_len, data->th_flags | TH_PUSH) != 0)) + return -1; + + conn->tcp.client_fin_pending = 1; + conn->tcp.client_closed = 1; + return 0; + } + if(conn->sock != INVALID_SOCKET) { // Half close the socket to possibly signal the remote server // (e.g. in after an HTTP request is completed). @@ -1754,6 +1778,18 @@ static int handle_tcp_reply(zdtun_t *tun, zdtun_conn_t *conn) { close_socket(tun, conn->sock); conn->sock = INVALID_SOCKET; + if(conn->tcp.client_fin_pending) { + // The bytes queued before the client FIN can no longer be delivered: ACK them and the FIN + // as for a write after the server closed, so the client can finish its close. + conn->tcp.client_fin_pending = 0; + conn->tcp.client_seq += conn->tcp.tx_queue_size + 1; + conn->tcp.tx_queue_size = 0; + build_reply_tcpip(tun, conn, TH_ACK, 0, 0); + + if(send_to_client(tun, conn, TCP_HEADER_LEN) != 0) + return 0; + } + if(conn->tcp.client_closed) // Both the client and the server have closed, terminate the connection zdtun_conn_close(tun, conn, CONN_STATUS_CLOSED); @@ -1919,9 +1955,21 @@ static int handle_queued_tcp_data(zdtun_t *tun, zdtun_conn_t *conn) { // no more data to send FD_CLR(conn->sock, &tun->write_fds); - if(sent > 0) { + uint32_t acked = sent; + + if(!conn->tcp.tx_queue && conn->tcp.client_fin_pending) { + // Everything before the client FIN reached the server: forward the FIN and ACK it too + conn->tcp.client_fin_pending = 0; + + if(shutdown(conn->sock, SHUT_WR) != 0) + debug("shutdown failed[%d] %s", errno, strerror(errno)); + + acked += 1; + } + + if(acked > 0) { // ACK the sent packets - conn->tcp.client_seq += sent; + conn->tcp.client_seq += acked; conn->tcp.tx_queue_size -= sent; build_reply_tcpip(tun, conn, TH_ACK, 0, 0); From cdf5446036e106baa42ef51d10890a11da5ea700 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 6 Nov 2026 23:58:06 +0900 Subject: [PATCH 07/98] Add host regression tests for the zdtun forwarding fixes zdtun.c is compiled into a host test with in-memory fake sockets; run app/src/test/cpp/run-zdtun-host-test.sh on Linux or macOS. Also list the local zdtun modifications in THIRD_PARTY_NOTICES.md. Covers sec-native-forward-path-1, -2, -3 and -4. --- THIRD_PARTY_NOTICES.md | 10 + app/src/test/cpp/host-shim/linux/sockios.h | 10 + app/src/test/cpp/run-zdtun-host-test.sh | 15 + app/src/test/cpp/zdtun_host_test.c | 707 +++++++++++++++++++++ 4 files changed, 742 insertions(+) create mode 100644 app/src/test/cpp/host-shim/linux/sockios.h create mode 100755 app/src/test/cpp/run-zdtun-host-test.sh create mode 100644 app/src/test/cpp/zdtun_host_test.c diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a8a905b..5080dd8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -10,6 +10,16 @@ zdtun is dynamically packaged as `libzdtun.so`; mobileGF2logger's JNI integration is packaged separately as `libgf2capture.so`. +Local modifications to the bundled zdtun (under the same LGPL-3.0 terms): + +- The open-socket counter cannot underflow. +- A TCP or ICMP connection whose socket cannot be opened is closed through `zdtun_conn_close`, so `on_connection_close` runs and the TCP client gets an RST for its SYN. +- When the socket cap forces a purge, connections without a socket are not counted, and the rest go in the order UDP/ICMP, TCP, then connections an optional `on_purge_priority` callback asks to keep. +- DNS flags are converted to host byte order before the QR bit is tested. +- A client FIN is forwarded to the server and ACKed only after every byte before it, including the FIN segment's own payload, has been sent. + +Host regression tests for these changes are in `app/src/test/cpp` (`run-zdtun-host-test.sh`). + ## gfl2logger protocol reference - Project: [blead/gfl2logger](https://github.com/blead/gfl2logger) diff --git a/app/src/test/cpp/host-shim/linux/sockios.h b/app/src/test/cpp/host-shim/linux/sockios.h new file mode 100644 index 0000000..58d7479 --- /dev/null +++ b/app/src/test/cpp/host-shim/linux/sockios.h @@ -0,0 +1,10 @@ +/* Fallback for hosts without Linux headers (macOS). It is only reached through -idirafter, so a + * Linux host keeps its real header. The value is never interpreted: the host test stubs ioctl. */ +#ifndef ZDTUN_HOST_SHIM_SOCKIOS_H +#define ZDTUN_HOST_SHIM_SOCKIOS_H + +#ifndef SIOCOUTQ +#define SIOCOUTQ 0x5411 +#endif + +#endif diff --git a/app/src/test/cpp/run-zdtun-host-test.sh b/app/src/test/cpp/run-zdtun-host-test.sh new file mode 100755 index 0000000..8cb2943 --- /dev/null +++ b/app/src/test/cpp/run-zdtun-host-test.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Builds and runs the zdtun host regression tests with the host C compiler (Linux or macOS). +# Usage: app/src/test/cpp/run-zdtun-host-test.sh (CC overrides the compiler) +set -eu + +here=$(cd "$(dirname "$0")" && pwd) +zdtun="$here/../../main/cpp/third_party/zdtun" +out=$(mktemp "${TMPDIR:-/tmp}/zdtun-host-test.XXXXXX") +trap 'rm -f "$out"' EXIT + +${CC:-cc} -std=gnu11 -D_GNU_SOURCE -DNO_DEBUG \ + -Wall -Wextra -Wno-unused-function -Wno-unused-variable -Wno-unused-parameter -Wno-sign-compare \ + -I"$zdtun" -idirafter "$here/host-shim" \ + "$here/zdtun_host_test.c" -o "$out" +"$out" diff --git a/app/src/test/cpp/zdtun_host_test.c b/app/src/test/cpp/zdtun_host_test.c new file mode 100644 index 0000000..2065e8f --- /dev/null +++ b/app/src/test/cpp/zdtun_host_test.c @@ -0,0 +1,707 @@ +/* + * Host regression tests for the bundled zdtun forwarding core. + * + * zdtun.c is compiled into this file so the tests can reach its private connection state, and + * every socket call it makes is redirected to an in-memory fake: no network, root or device is + * needed. Build and run with app/src/test/cpp/run-zdtun-host-test.sh on Linux or macOS. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_LITTLE_ENDIAN) && (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) +#define _LITTLE_ENDIAN 1 +#endif + +#include "zdtun.h" + +#ifndef MSG_MORE +#define MSG_MORE 0 +#endif +#ifndef CLOCK_MONOTONIC_COARSE +#define CLOCK_MONOTONIC_COARSE CLOCK_MONOTONIC +#endif +#if !defined(IPV6_ADD_MEMBERSHIP) && defined(IPV6_JOIN_GROUP) +#define IPV6_ADD_MEMBERSHIP IPV6_JOIN_GROUP +#endif + +/* ******************************************************* */ +/* Fake sockets: real descriptors (dups of /dev/null) so FD_SET and close work, fake behaviour. */ + +typedef struct { + bool shut_wr; + size_t sent_at_shutdown; + bool send_blocked; // send fails with EAGAIN + char sent[4096]; + size_t sent_len; + char rx[512]; + size_t rx_len; + bool rx_eof; +} fake_socket_t; + +static fake_socket_t fakes[FD_SETSIZE]; +static int devnull_fd = -1; + +static int stub_socket(int domain, int type, int protocol) { + (void) domain; (void) type; (void) protocol; + int fd = dup(devnull_fd); + + if((fd >= 0) && (fd < FD_SETSIZE)) + memset(&fakes[fd], 0, sizeof(fakes[fd])); + return fd; +} + +static int stub_connect(int fd, const void *addr, socklen_t len) { + (void) fd; (void) addr; (void) len; + return 0; +} + +static ssize_t stub_send(int fd, const void *buf, size_t len, int flags) { + fake_socket_t *fake = &fakes[fd]; + (void) flags; + + if(fake->shut_wr) { + errno = EPIPE; + return -1; + } + if(fake->send_blocked) { + errno = EAGAIN; + return -1; + } + if(fake->sent_len + len > sizeof(fake->sent)) { + errno = ENOBUFS; + return -1; + } + + memcpy(fake->sent + fake->sent_len, buf, len); + fake->sent_len += len; + return (ssize_t) len; +} + +static ssize_t stub_sendto(int fd, const void *buf, size_t len, int flags, + const void *addr, socklen_t addrlen) { + (void) fd; (void) buf; (void) flags; (void) addr; (void) addrlen; + return (ssize_t) len; +} + +static ssize_t stub_recv(int fd, void *buf, size_t len, int flags) { + fake_socket_t *fake = &fakes[fd]; + (void) flags; + + if(fake->rx_len > 0) { + size_t n = (fake->rx_len < len) ? fake->rx_len : len; + memcpy(buf, fake->rx, n); + fake->rx_len = 0; + return (ssize_t) n; + } + if(fake->rx_eof) + return 0; + + errno = EAGAIN; + return -1; +} + +static int stub_shutdown(int fd, int how) { + (void) how; + fakes[fd].shut_wr = true; + fakes[fd].sent_at_shutdown = fakes[fd].sent_len; + return 0; +} + +static int stub_setsockopt(int fd, int level, int name, const void *val, socklen_t len) { + (void) fd; (void) level; (void) name; (void) val; (void) len; + return 0; +} + +static int stub_getsockopt(int fd, int level, int name, void *val, socklen_t *len) { + (void) fd; (void) level; (void) name; + memset(val, 0, *len); + return 0; +} + +static int stub_bind(int fd, const void *addr, socklen_t len) { + (void) fd; (void) addr; (void) len; + return 0; +} + +static int stub_getsockname(int fd, void *addr, socklen_t *len) { + struct sockaddr_in *addr4 = (struct sockaddr_in *) addr; + (void) len; + addr4->sin_port = htons((uint16_t) (40000 + fd)); + return 0; +} + +static int stub_ioctl(int fd, unsigned long request, void *arg) { + (void) fd; (void) request; + *(int *) arg = 0; + return 0; +} + +#define socket(d, t, p) stub_socket(d, t, p) +#define connect(s, a, l) stub_connect(s, a, l) +#define send(s, b, l, f) stub_send(s, b, l, f) +#define sendto(s, b, l, f, a, al) stub_sendto(s, b, l, f, a, al) +#define recv(s, b, l, f) stub_recv(s, b, l, f) +#define shutdown(s, h) stub_shutdown(s, h) +#define setsockopt(s, l, n, v, vl) stub_setsockopt(s, l, n, v, vl) +#define getsockopt(s, l, n, v, vl) stub_getsockopt(s, l, n, v, vl) +#define bind(s, a, l) stub_bind(s, a, l) +#define getsockname(s, a, l) stub_getsockname(s, a, l) +#define ioctl(s, r, a) stub_ioctl(s, r, a) + +#include "zdtun.c" + +#undef socket +#undef connect +#undef send +#undef sendto +#undef recv +#undef shutdown +#undef setsockopt +#undef getsockopt +#undef bind +#undef getsockname +#undef ioctl + +// utils.c needs Linux TUN headers; only its checksum is used by zdtun.c. +u_int16_t calc_checksum(u_int16_t start, const u_int8_t *buffer, u_int16_t length) { + uint32_t sum = start; + + for(u_int16_t i = 0; i + 1 < length; i += 2) + sum += (uint32_t) ((buffer[i] << 8) | buffer[i + 1]); + if(length & 1) + sum += (uint32_t) (buffer[length - 1] << 8); + while(sum >> 16) + sum = (sum & 0xFFFF) + (sum >> 16); + + return (u_int16_t) sum; +} + +/* ******************************************************* */ +/* Test harness */ + +static int failures = 0; +static const char *current_test = ""; + +#define CHECK(cond) do { \ + if(!(cond)) { \ + fprintf(stderr, "FAIL %s (%s:%d): %s\n", current_test, __FILE__, __LINE__, #cond); \ + failures++; \ + } \ +} while(0) + +typedef struct { + bool keep; +} test_flow_t; + +typedef struct { + uint16_t client_port; // host order + uint8_t flags; + uint32_t seq; + uint32_t ack; + uint16_t payload_len; +} reply_t; + +static reply_t replies[512]; +static int num_replies; +static int num_opens; +static int num_closes; +static int live_flows; + +static void reset_counters(void) { + num_replies = 0; + num_opens = 0; + num_closes = 0; + live_flows = 0; +} + +static int on_send_client(zdtun_t *tun, zdtun_pkt_t *pkt, const zdtun_conn_t *conn) { + (void) tun; (void) conn; + + if((pkt->tuple.ipproto == IPPROTO_TCP) && (num_replies < (int) (sizeof(replies) / sizeof(replies[0])))) { + reply_t *reply = &replies[num_replies++]; + + reply->client_port = ntohs(pkt->tcp->th_dport); + reply->flags = pkt->tcp->th_flags; + reply->seq = ntohl(pkt->tcp->th_seq); + reply->ack = ntohl(pkt->tcp->th_ack); + reply->payload_len = pkt->l7_len; + } + return 0; +} + +// Like gf2capture: only TCP flows carry state, and all of them start out inspected. +static int on_open(zdtun_t *tun, zdtun_conn_t *conn) { + (void) tun; + num_opens++; + + if(zdtun_conn_get_5tuple(conn)->ipproto == IPPROTO_TCP) { + test_flow_t *flow = calloc(1, sizeof(test_flow_t)); + flow->keep = true; + zdtun_conn_set_userdata(conn, flow); + live_flows++; + } + return 0; +} + +static void on_close(zdtun_t *tun, const zdtun_conn_t *conn) { + (void) tun; + num_closes++; + + test_flow_t *flow = zdtun_conn_get_userdata(conn); + if(flow) { + zdtun_conn_set_userdata((zdtun_conn_t *) conn, NULL); + free(flow); + live_flows--; + } +} + +static int on_purge_priority(zdtun_t *tun, const zdtun_conn_t *conn) { + (void) tun; + const test_flow_t *flow = zdtun_conn_get_userdata(conn); + return (flow && flow->keep) ? 1 : 0; +} + +static zdtun_t *new_tunnel(void) { + zdtun_callbacks_t callbacks = { + .send_client = on_send_client, + .on_connection_open = on_open, + .on_connection_close = on_close, + .on_purge_priority = on_purge_priority, + }; + + reset_counters(); + return zdtun_init(&callbacks, NULL); +} + +/* ******************************************************* */ +/* Client packets */ + +#define CLIENT_IP htonl(0x0A000002) // 10.0.0.2 +#define SERVER_IP htonl(0x08080808) // 8.8.8.8 + +static char pkt_buf[2048]; + +static int build_ipv4(uint8_t proto, uint16_t l4_len) { + struct iphdr *ip = (struct iphdr *) pkt_buf; + + memset(pkt_buf, 0, sizeof(pkt_buf)); + ip->version = 4; + ip->ihl = 5; + ip->tot_len = htons(IPV4_HEADER_LEN + l4_len); + ip->frag_off = htons(0x4000); + ip->ttl = 64; + ip->protocol = proto; + ip->saddr = CLIENT_IP; + ip->daddr = SERVER_IP; + return IPV4_HEADER_LEN + l4_len; +} + +static int build_tcp(uint16_t sport, uint8_t flags, uint32_t seq, const char *payload, uint16_t len) { + int total = build_ipv4(IPPROTO_TCP, TCP_HEADER_LEN + len); + struct tcphdr *tcp = (struct tcphdr *) (pkt_buf + IPV4_HEADER_LEN); + + tcp->th_sport = htons(sport); + tcp->th_dport = htons(443); + tcp->th_seq = htonl(seq); + tcp->th_ack = htonl(0x77EB77EC); + tcp->th_off = 5; + tcp->th_flags = flags; + tcp->th_win = htons(65535); + if(len > 0) + memcpy(pkt_buf + IPV4_HEADER_LEN + TCP_HEADER_LEN, payload, len); + return total; +} + +static int build_udp(uint16_t sport, uint16_t dport, const uint8_t *payload, uint16_t len) { + int total = build_ipv4(IPPROTO_UDP, UDP_HEADER_LEN + len); + struct udphdr *udp = (struct udphdr *) (pkt_buf + IPV4_HEADER_LEN); + + udp->uh_sport = htons(sport); + udp->uh_dport = htons(dport); + udp->uh_ulen = htons(UDP_HEADER_LEN + len); + memcpy(pkt_buf + IPV4_HEADER_LEN + UDP_HEADER_LEN, payload, len); + return total; +} + +static int build_icmp_echo(uint16_t id) { + int total = build_ipv4(IPPROTO_ICMP, sizeof(struct icmphdr)); + struct icmphdr *icmp = (struct icmphdr *) (pkt_buf + IPV4_HEADER_LEN); + + icmp->type = ICMP_ECHO; + icmp->un.echo.id = htons(id); + icmp->un.echo.sequence = htons(1); + return total; +} + +// Mirrors gf2capture.c process_outgoing_packet. Returns the looked-up connection, even if closed. +static zdtun_conn_t *client_send(zdtun_t *tun, int len) { + zdtun_pkt_t pkt; + + if(zdtun_parse_pkt(tun, pkt_buf, (uint16_t) len, &pkt) != 0) + return NULL; + + bool established = (pkt.tuple.ipproto == IPPROTO_TCP) && + (!(pkt.tcp->th_flags & TH_SYN) || (pkt.tcp->th_flags & TH_ACK)); + zdtun_conn_t *conn = zdtun_lookup(tun, &pkt.tuple, established ? 0 : 1); + + if(conn && (zdtun_forward(tun, &pkt, conn) != 0)) + zdtun_conn_close(tun, conn, CONN_STATUS_ERROR); + return conn; +} + +static zdtun_conn_t *open_tcp(zdtun_t *tun, uint16_t sport, uint32_t isn) { + return client_send(tun, build_tcp(sport, TH_SYN, isn, NULL, 0)); +} + +static zdtun_conn_t *open_udp(zdtun_t *tun, uint16_t sport) { + const uint8_t payload[] = {'x'}; + return client_send(tun, build_udp(sport, 9999, payload, sizeof(payload))); +} + +static void handle_fd(zdtun_t *tun, const zdtun_conn_t *conn, bool readable, bool writable) { + fd_set rd, wr; + + FD_ZERO(&rd); + FD_ZERO(&wr); + if(readable) + FD_SET(conn->sock, &rd); + if(writable) + FD_SET(conn->sock, &wr); + zdtun_handle_fd(tun, &rd, &wr); +} + +static int count_replies(uint16_t client_port, uint8_t flag) { + int count = 0; + + for(int i = 0; i < num_replies; i++) { + if((replies[i].client_port == client_port) && (replies[i].flags & flag)) + count++; + } + return count; +} + +static const reply_t *last_reply(void) { + return (num_replies > 0) ? &replies[num_replies - 1] : NULL; +} + +static bool is_live(zdtun_t *tun, const zdtun_conn_t *conn) { + zdtun_conn_t *found = NULL; + + HASH_FIND(hh, tun->conn_table, &conn->tuple, sizeof(conn->tuple), found); + return (found == conn) && (found->status < CONN_STATUS_CLOSED); +} + +/* ******************************************************* */ +/* sec-native-forward-path-1: a refused socket still closes the flow exactly once */ + +static void test_tcp_socket_refusal_closes_flow_and_resets_client(void) { + current_test = __func__; + zdtun_t *tun = new_tunnel(); + + tun->stats.num_open_sockets = MAX_NUM_SOCKETS; // nothing to purge, so the cap refuses the socket + zdtun_conn_t *conn = open_tcp(tun, 40001, 1000); + + CHECK(conn != NULL); + CHECK(num_opens == 1); + CHECK(num_closes == 1); + CHECK(live_flows == 0); + CHECK(conn && (conn->status == CONN_STATUS_SOCKET_ERROR)); + CHECK(num_replies == 1); + CHECK(last_reply() && (last_reply()->flags == (TH_RST | TH_ACK))); + CHECK(last_reply() && (last_reply()->ack == 1001)); // acknowledges the SYN, so SYN-SENT accepts it + + // A retransmitted SYN gets a fresh flow, which is again opened and closed once. + open_tcp(tun, 40001, 1000); + CHECK(num_opens == 2); + CHECK(num_closes == 2); + CHECK(live_flows == 0); + CHECK(count_replies(40001, TH_RST) == 2); + + tun->stats.num_open_sockets = 0; + zdtun_finalize(tun); + CHECK(num_closes == 2); +} + +static void test_icmp_socket_refusal_closes_flow(void) { + current_test = __func__; + zdtun_t *tun = new_tunnel(); + + tun->stats.num_open_sockets = MAX_NUM_SOCKETS; + zdtun_conn_t *conn = client_send(tun, build_icmp_echo(7)); + + CHECK(conn != NULL); + CHECK(num_opens == 1); + CHECK(num_closes == 1); + CHECK(conn && (conn->status == CONN_STATUS_SOCKET_ERROR)); + + tun->stats.num_open_sockets = 0; + zdtun_finalize(tun); + CHECK(num_closes == 1); +} + +/* ******************************************************* */ +/* sec-native-forward-path-2: the forced purge spares the flows that may carry game frames */ + +static void test_forced_purge_takes_datagrams_before_tcp(void) { + current_test = __func__; + zdtun_t *tun = new_tunnel(); + + zdtun_conn_t *inspected = open_tcp(tun, 41000, 1); + zdtun_conn_t *plain = open_tcp(tun, 41001, 1); + CHECK(inspected && (inspected->status == CONN_STATUS_CONNECTED)); + CHECK(plain && (plain->status == CONN_STATUS_CONNECTED)); + ((test_flow_t *) plain->user_data)->keep = false; + + // Both TCP flows are the oldest connections, which the old timestamp-only purge took first. + inspected->tstamp -= 30; + plain->tstamp -= 20; + + for(uint16_t port = 0; tun->stats.num_open_sockets < MAX_NUM_SOCKETS; port++) + open_udp(tun, (uint16_t) (50000 + port)); + CHECK(tun->stats.num_open_sockets == MAX_NUM_SOCKETS); + + num_replies = 0; + zdtun_conn_t *fresh = open_tcp(tun, 41002, 1); // at the cap, so the lookup forces a purge + + CHECK(is_live(tun, inspected)); + CHECK(is_live(tun, plain)); + CHECK(fresh && is_live(tun, fresh)); + CHECK(count_replies(41000, TH_RST) == 0); + CHECK(count_replies(41001, TH_RST) == 0); + CHECK(tun->stats.num_open_sockets == NUM_SOCKETS_AFTER_PURGE + 1); + CHECK(tun->stats.num_udp_conn == (MAX_NUM_SOCKETS - 2) - (MAX_NUM_SOCKETS - NUM_SOCKETS_AFTER_PURGE)); + + zdtun_finalize(tun); + CHECK(live_flows == 0); +} + +static void test_forced_purge_takes_plain_tcp_before_inspected_tcp(void) { + current_test = __func__; + zdtun_t *tun = new_tunnel(); + + zdtun_conn_t *inspected = open_tcp(tun, 42000, 1); + CHECK(inspected != NULL); + inspected->tstamp -= 30; + + for(uint16_t port = 1; tun->stats.num_open_sockets < MAX_NUM_SOCKETS; port++) { + zdtun_conn_t *conn = open_tcp(tun, (uint16_t) (42000 + port), 1); + ((test_flow_t *) conn->user_data)->keep = false; + } + + num_replies = 0; + zdtun_purge_expired(tun); + + CHECK(is_live(tun, inspected)); + CHECK(count_replies(42000, TH_RST) == 0); + CHECK(tun->stats.num_open_sockets == NUM_SOCKETS_AFTER_PURGE); + CHECK(num_replies == MAX_NUM_SOCKETS - NUM_SOCKETS_AFTER_PURGE); // one RST per purged plain flow + + zdtun_finalize(tun); + CHECK(live_flows == 0); +} + +static void test_forced_purge_does_not_count_socketless_connections(void) { + current_test = __func__; + zdtun_t *tun = new_tunnel(); + + // The server closes first: the connection waits for the client FIN without a socket. + zdtun_conn_t *half_closed = open_tcp(tun, 43000, 1); + CHECK(half_closed != NULL); + fakes[half_closed->sock].rx_eof = true; + handle_fd(tun, half_closed, true, false); + CHECK(half_closed->sock == INVALID_SOCKET); + CHECK(half_closed->status == CONN_STATUS_CONNECTED); + half_closed->tstamp -= 30; + + for(uint16_t port = 0; tun->stats.num_open_sockets < MAX_NUM_SOCKETS; port++) + open_udp(tun, (uint16_t) (50000 + port)); + + zdtun_purge_expired(tun); + + // Destroying the socketless connection freed no socket, so it must not count toward the purge. + CHECK(tun->stats.num_open_sockets == NUM_SOCKETS_AFTER_PURGE); + CHECK(live_flows == 0); + + zdtun_finalize(tun); +} + +/* ******************************************************* */ +/* sec-native-forward-path-3: the DNS QR bit is read in host order */ + +static void test_dns_flags_read_in_network_order(void) { + current_test = __func__; + zdtun_t *tun = new_tunnel(); + // transaction id, flags, 1 question, 0 answers, 0 authority, 0 additional, first label byte + const uint8_t query[] = {0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0x00}; + const uint8_t response[] = {0x12, 0x34, 0x81, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0x00}; + const uint8_t recursive_query[] = {0x56, 0x78, 0x01, 0x80, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0x00}; + + zdtun_conn_t *conn = client_send(tun, build_udp(45000, 53, query, sizeof(query))); + CHECK(conn && (conn->dns.pending_queries == 1)); + + // RA=0 response: QR alone must mark it as the answer and close the flow at once. + memcpy(fakes[conn->sock].rx, response, sizeof(response)); + fakes[conn->sock].rx_len = sizeof(response); + handle_fd(tun, conn, true, false); + CHECK(conn->dns.pending_queries == 0); + CHECK(conn->status >= CONN_STATUS_CLOSED); + + // RA=1 query: still a query. + zdtun_conn_t *other = client_send(tun, build_udp(45001, 53, recursive_query, sizeof(recursive_query))); + CHECK(other && (other->dns.pending_queries == 1)); + + zdtun_finalize(tun); +} + +/* ******************************************************* */ +/* sec-native-forward-path-4: the client FIN follows every byte before it */ + +#define ISN 5000 + +static zdtun_conn_t *established_tcp(zdtun_t *tun, uint16_t sport) { + zdtun_conn_t *conn = open_tcp(tun, sport, ISN); + + CHECK(conn && (conn->status == CONN_STATUS_CONNECTED)); + CHECK(conn && (conn->tcp.client_seq == ISN + 1)); + return conn; +} + +static void test_fin_waits_for_queued_bytes(void) { + current_test = __func__; + zdtun_t *tun = new_tunnel(); + zdtun_conn_t *conn = established_tcp(tun, 46000); + fake_socket_t *server = &fakes[conn->sock]; + + server->send_blocked = true; + client_send(tun, build_tcp(46000, TH_PUSH | TH_ACK, ISN + 1, "hello", 5)); + handle_fd(tun, conn, false, true); + CHECK(conn->tcp.tx_queue_size == 5); + + num_replies = 0; + client_send(tun, build_tcp(46000, TH_FIN | TH_ACK, ISN + 6, "abc", 3)); + CHECK(!server->shut_wr); + CHECK(num_replies == 0); // neither the payload nor the FIN is ACKed before it is sent + + // A retransmission of the same FIN changes nothing. + client_send(tun, build_tcp(46000, TH_FIN | TH_ACK, ISN + 6, "abc", 3)); + CHECK(conn->tcp.tx_queue_size == 8); + CHECK(num_replies == 0); + + server->send_blocked = false; + handle_fd(tun, conn, false, true); + CHECK(server->sent_len == 8); + CHECK(memcmp(server->sent, "helloabc", 8) == 0); + CHECK(server->shut_wr); + CHECK(server->sent_at_shutdown == 8); + CHECK(last_reply() && (last_reply()->flags == TH_ACK)); + CHECK(last_reply() && (last_reply()->ack == ISN + 1 + 5 + 3 + 1)); + CHECK(count_replies(46000, TH_RST) == 0); + + // A FIN retransmitted after it was ACKed is ACKed again, not counted twice. + client_send(tun, build_tcp(46000, TH_FIN | TH_ACK, ISN + 9, NULL, 0)); + CHECK(conn->tcp.client_seq == ISN + 1 + 5 + 3 + 1); + CHECK(last_reply() && (last_reply()->ack == ISN + 1 + 5 + 3 + 1)); + CHECK(is_live(tun, conn)); + + zdtun_finalize(tun); +} + +static void test_fin_payload_is_delivered(void) { + current_test = __func__; + zdtun_t *tun = new_tunnel(); + zdtun_conn_t *conn = established_tcp(tun, 46001); + fake_socket_t *server = &fakes[conn->sock]; + + client_send(tun, build_tcp(46001, TH_FIN | TH_ACK, ISN + 1, "bye", 3)); + CHECK(!server->shut_wr); + + handle_fd(tun, conn, false, true); + CHECK(server->sent_len == 3); + CHECK(memcmp(server->sent, "bye", 3) == 0); + CHECK(server->shut_wr && (server->sent_at_shutdown == 3)); + CHECK(last_reply() && (last_reply()->ack == ISN + 1 + 3 + 1)); + CHECK(count_replies(46001, TH_RST) == 0); + + zdtun_finalize(tun); +} + +static void test_bare_fin_is_forwarded_at_once(void) { + current_test = __func__; + zdtun_t *tun = new_tunnel(); + zdtun_conn_t *conn = established_tcp(tun, 46002); + fake_socket_t *server = &fakes[conn->sock]; + + client_send(tun, build_tcp(46002, TH_FIN | TH_ACK, ISN + 1, NULL, 0)); + CHECK(server->shut_wr); + CHECK(last_reply() && (last_reply()->flags == TH_ACK)); + CHECK(last_reply() && (last_reply()->ack == ISN + 2)); + + zdtun_finalize(tun); +} + +static void test_server_close_acks_pending_fin(void) { + current_test = __func__; + zdtun_t *tun = new_tunnel(); + zdtun_conn_t *conn = established_tcp(tun, 46003); + fake_socket_t *server = &fakes[conn->sock]; + + server->send_blocked = true; + client_send(tun, build_tcp(46003, TH_PUSH | TH_ACK, ISN + 1, "hello", 5)); + client_send(tun, build_tcp(46003, TH_FIN | TH_ACK, ISN + 6, NULL, 0)); + CHECK(!server->shut_wr); + + num_replies = 0; + server->rx_eof = true; + handle_fd(tun, conn, true, false); + + // The server is gone: its FIN reaches the client, and the client's FIN is ACKed so its close + // completes without an RST. + CHECK(count_replies(46003, TH_FIN) == 1); + CHECK(last_reply() && (last_reply()->flags == TH_ACK)); + CHECK(last_reply() && (last_reply()->ack == ISN + 1 + 5 + 1)); + CHECK(count_replies(46003, TH_RST) == 0); + CHECK(conn->status >= CONN_STATUS_CLOSED); + + zdtun_finalize(tun); +} + +/* ******************************************************* */ + +int main(void) { + devnull_fd = open("/dev/null", O_RDWR); + if(devnull_fd < 0) { + perror("open /dev/null"); + return 2; + } + + test_tcp_socket_refusal_closes_flow_and_resets_client(); + test_icmp_socket_refusal_closes_flow(); + test_forced_purge_takes_datagrams_before_tcp(); + test_forced_purge_takes_plain_tcp_before_inspected_tcp(); + test_forced_purge_does_not_count_socketless_connections(); + test_dns_flags_read_in_network_order(); + test_fin_waits_for_queued_bytes(); + test_fin_payload_is_delivered(); + test_bare_fin_is_forwarded_at_once(); + test_server_close_acks_pending_fin(); + + if(failures) { + fprintf(stderr, "%d zdtun host check(s) failed\n", failures); + return 1; + } + + printf("zdtun host tests passed\n"); + return 0; +} From 2346e325beb73e75de324c47f76b82dca393ebae Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:00:21 +0900 Subject: [PATCH 08/98] Keep reset and capture regions honest across clears, restores and migrations The legacy global region seeds each client's capture region at most once, so clearing it after the last Platoon is deleted sticks. A settings replay keeps an automatic reset region automatic, never restores another publisher's region onto a Platoon, and the picker offers only that publisher's servers. A settings migration that cannot be written no longer crashes every cold start. Findings: input-settings-prefs-3, input-settings-prefs-4, input-settings-prefs-m2, input-settings-prefs-6 --- .../ScopedRegionSettingsIntegrationTest.kt | 126 ++++++++++++++++++ .../java/dev/gf2log/app/OptionsActivity.kt | 11 +- .../app/management/PlatoonBackupManager.kt | 2 +- .../gf2log/app/settings/AppSettingsStore.kt | 44 +++++- .../settings/ClientServerRegionPreferences.kt | 24 +++- .../app/settings/UserSettingsPreferences.kt | 27 +++- .../dev/gf2log/app/ResetRegionChoicesTest.kt | 29 ++++ .../settings/ScopedResetRegionPolicyTest.kt | 69 ++++++++++ .../app/settings/UserSettingsMigrationTest.kt | 98 ++++++++++++++ 9 files changed, 413 insertions(+), 17 deletions(-) create mode 100644 app/src/androidTest/java/dev/gf2log/app/settings/ScopedRegionSettingsIntegrationTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/ResetRegionChoicesTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/settings/ScopedResetRegionPolicyTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/settings/UserSettingsMigrationTest.kt diff --git a/app/src/androidTest/java/dev/gf2log/app/settings/ScopedRegionSettingsIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/settings/ScopedRegionSettingsIntegrationTest.kt new file mode 100644 index 0000000..b32e13e --- /dev/null +++ b/app/src/androidTest/java/dev/gf2log/app/settings/ScopedRegionSettingsIntegrationTest.kt @@ -0,0 +1,126 @@ +package dev.gf2log.app.settings + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.gf2log.app.SupportedGamePackages +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.protocol.model.PlatoonProfileData +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ScopedRegionSettingsIntegrationTest { + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + clearState() + } + + @After + fun tearDown() = clearState() + + @Test + fun theLegacyGlobalRegionSeedsAClientOnlyOnce() { + legacyGlobalRegion(GameServerRegion.HAOPLAY_KOREA) + val preferences = ClientServerRegionPreferences(context) + + assertEquals(GameServerRegion.HAOPLAY_KOREA, preferences.configured(SupportedGamePackages.HAOPLAY)) + assertEquals(GameServerRegion.HAOPLAY_KOREA, preferences.stored(SupportedGamePackages.HAOPLAY)) + assertNull(preferences.configured(SupportedGamePackages.DARKWINTER)) + + preferences.clear(SupportedGamePackages.HAOPLAY) + + assertNull(preferences.configured(SupportedGamePackages.HAOPLAY)) + // The global key itself is left untouched. + assertEquals(GameServerRegion.HAOPLAY_KOREA.storedValue, UserSettingsPreferences.gameServerRegion(context)) + } + + @Test + fun clearingBeforeAnyMigrationStillSticks() { + legacyGlobalRegion(GameServerRegion.HAOPLAY_KOREA) + val preferences = ClientServerRegionPreferences(context) + + preferences.clear(SupportedGamePackages.HAOPLAY) + + assertNull(preferences.configured(SupportedGamePackages.HAOPLAY)) + } + + @Test + fun aManualLegacyRegionConfiguresNoClient() { + legacyGlobalRegion(GameServerRegion.MANUAL) + val preferences = ClientServerRegionPreferences(context) + + assertNull(preferences.configured(SupportedGamePackages.HAOPLAY)) + assertNull(preferences.configured(SupportedGamePackages.DARKWINTER)) + } + + @Test + fun aSettingsRoundTripKeepsAnAutomaticResetRegionAutomatic() { + val storageId = haoPlayKoreaProfile() + val store = ScopedAppSettingsStore(context, storageId) + assertTrue(GameTimeZonePreferences.isAutomatic(context, storageId)) + + store.replace(store.read()) + + assertTrue(GameTimeZonePreferences.isAutomatic(context, storageId)) + assertEquals(GameServerRegion.HAOPLAY_KOREA, GameTimeZonePreferences.region(context, storageId)) + } + + @Test + fun aSettingsRoundTripKeepsADeliberateOverride() { + val storageId = haoPlayKoreaProfile() + GameTimeZonePreferences.setRegion(context, GameServerRegion.HAOPLAY_GLOBAL, storageId) + val store = ScopedAppSettingsStore(context, storageId) + + store.replace(store.read()) + + assertFalse(GameTimeZonePreferences.isAutomatic(context, storageId)) + assertEquals(GameServerRegion.HAOPLAY_GLOBAL, GameTimeZonePreferences.region(context, storageId)) + } + + @Test + fun anotherPublishersRegionIsNotRestoredOntoAPlatoon() { + val storageId = haoPlayKoreaProfile() + val store = ScopedAppSettingsStore(context, storageId) + + store.replace(store.read().copy(gameServerRegion = GameServerRegion.DARKWINTER_GLOBAL.storedValue)) + + assertTrue(GameTimeZonePreferences.isAutomatic(context, storageId)) + assertEquals(GameServerRegion.HAOPLAY_KOREA, GameTimeZonePreferences.region(context, storageId)) + } + + private fun haoPlayKoreaProfile(): String = PlatoonProfileRegistry(context).upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(101817u, "Owls", 2u, 3u), + ).storageId + + private fun legacyGlobalRegion(region: GameServerRegion) { + UserSettingsPreferences.read(context) + context.getSharedPreferences("user_settings", Context.MODE_PRIVATE).edit() + .putString("game_server_region", region.storedValue) + .commit() + } + + private fun clearState() { + listOf( + "user_settings", + ClientServerRegionPreferences.PREFERENCES, + "platoon_profiles", + "platoon_timezones", + "platoon_member_order", + "platoon_weekly_cutlines", + ).forEach { name -> + context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit() + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 70d3060..eeab6d5 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -6,6 +6,7 @@ import android.os.Build import android.os.Bundle import android.widget.LinearLayout import dev.gf2log.app.capture.CaptureDiagnosticsStore +import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.settings.CapturePreferences @@ -219,7 +220,7 @@ class OptionsActivity : BaseActivity() { } private fun chooseResetRegion(scope: PlatoonStorageScope) { - val regions = GameServerRegion.entries.filterNot { it == GameServerRegion.MANUAL } + val regions = resetRegionChoices(PlatoonProfileRegistry(this).find(scope.storageId)?.client?.packageName) val automatic = GameTimeZonePreferences.isAutomatic(this, scope.storageId) val current = GameTimeZonePreferences.region(this, scope.storageId) val checked = if (automatic) 0 else regions.indexOf(current).takeIf { it >= 0 }?.plus(1) ?: -1 @@ -331,3 +332,11 @@ class OptionsActivity : BaseActivity() { const val MAINTENANCE = "maintenance" } } + +/** + * Reset regions offered for a Platoon of [packageName]'s publisher, after Automatic. Another + * publisher's servers use offsets this Platoon's server never does, so they are not offered. + */ +internal fun resetRegionChoices(packageName: String?): List = + packageName?.let(ClientServerRegionPreferences::allowedFor) + ?: GameServerRegion.entries.filterNot { it == GameServerRegion.MANUAL } 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 0ca5ba5..b9edd6d 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -175,7 +175,7 @@ class PlatoonBackupManager internal constructor( } else { PlatoonBackupManager( context = appContext, - settingsStore = ScopedAppSettingsStore(appContext, scope.storageId), + settingsStore = ScopedAppSettingsStore(appContext, scope.storageId, restoredProfile), restoreObserver = restoreObserver, storageScope = scope, ) diff --git a/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt b/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt index 90ec5e6..6a12d9c 100644 --- a/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt +++ b/app/src/main/java/dev/gf2log/app/settings/AppSettingsStore.kt @@ -1,6 +1,8 @@ package dev.gf2log.app.settings import android.content.Context +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonProfileRegistry import java.time.ZoneId internal interface BackupSettingsStore { @@ -18,10 +20,15 @@ internal class AppSettingsStore(context: Context) : BackupSettingsStore { UserSettingsPreferences.replace(appContext, settings) } -/** Projects global presentation settings plus one Platoon's isolated reporting settings. */ +/** + * Projects global presentation settings plus one Platoon's isolated reporting settings. + * [restoredProfile] names the profile a restore installs after these settings; otherwise the + * scope's registered profile describes the Platoon. + */ internal class ScopedAppSettingsStore( context: Context, private val storageId: String, + private val restoredProfile: PlatoonProfile? = null, ) : BackupSettingsStore { private val appContext = context.applicationContext @@ -50,6 +57,8 @@ internal class ScopedAppSettingsStore( val previousOrder = memberOrder.read() val previousCutlines = cutlines.read() val previousZone = GameTimeZonePreferences.scopedStateOrNull(appContext, storageId) + val profile = (restoredProfile ?: PlatoonProfileRegistry(appContext).find(storageId)) + ?.takeUnless(PlatoonProfile::legacy) val rollbacks = ArrayDeque<() -> Unit>() try { UserSettingsPreferences.replace( @@ -67,10 +76,14 @@ internal class ScopedAppSettingsStore( GameTimeZonePreferences.restoreScopedState(appContext, storageId, previousZone) } } - if (manualZone != null) { - GameTimeZonePreferences.set(appContext, manualZone, storageId) - } else { - GameTimeZonePreferences.setRegion(appContext, region, storageId) + when { + manualZone != null -> GameTimeZonePreferences.set(appContext, manualZone, storageId) + ScopedResetRegionPolicy.restoresAutomatic( + region = region, + profileRegion = profile?.serverRegion, + allowed = profile?.let { ClientServerRegionPreferences.allowedFor(it.client.packageName) }, + ) -> GameTimeZonePreferences.clearRegionOverride(appContext, storageId) + else -> GameTimeZonePreferences.setRegion(appContext, region, storageId) } rollbacks.addFirst { check(memberOrder.write(previousOrder)) } check(memberOrder.write(settings.memberOrder)) { @@ -86,3 +99,24 @@ internal class ScopedAppSettingsStore( } } } + +/** + * Settings record only a scope's effective reset region, so replaying them must not turn the + * automatic choice into an explicit override, and a region the Platoon's publisher does not run + * can never become its reset region. + */ +internal object ScopedResetRegionPolicy { + /** + * True when restoring non-manual [region] clears the scope's override: it equals the profile's + * own region, or the profile's publisher does not run it. With no known profile, the region + * stays explicit and the restore reconciles it once the profile is installed. + */ + fun restoresAutomatic( + region: GameServerRegion, + profileRegion: GameServerRegion?, + allowed: List?, + ): Boolean { + if (region == GameServerRegion.MANUAL || profileRegion == null || allowed == null) return false + return region == profileRegion || region !in allowed + } +} diff --git a/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt index 0eb6d39..bd0f224 100644 --- a/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt @@ -13,12 +13,21 @@ internal class ClientServerRegionPreferences(context: Context) { return configured(packageName) ?: default(packageName) } - /** Returns only a persisted or migrated operator choice, never a guessed default. */ + /** + * Returns only a persisted or migrated operator choice, never a guessed default. The pre-2.4 + * global region seeds each client at most once; after that only this store answers, so a + * later [clear] leaves the client unconfigured. + */ fun configured(packageName: String): GameServerRegion? { require(packageName in SupportedGamePackages.all) stored(packageName)?.let { return it } - val legacy = GameTimeZonePreferences.legacyRegion(appContext) - return legacy.takeIf { it in allowed(packageName) } + if (preferences.getBoolean(legacyMigratedKey(packageName), false)) return null + val legacy = legacySeed(GameTimeZonePreferences.legacyRegion(appContext), packageName) + val editor = preferences.edit().putBoolean(legacyMigratedKey(packageName), true) + legacy?.let { editor.putString(packageName, it.storedValue) } + // An unwritten migration still answers this call and is retried by the next one. + editor.commit() + return legacy } fun set(packageName: String, region: GameServerRegion) { @@ -36,7 +45,8 @@ internal class ClientServerRegionPreferences(context: Context) { fun clear(packageName: String) { require(packageName in SupportedGamePackages.all) - check(preferences.edit().remove(packageName).commit()) { + // Marking the migration done keeps the legacy global region from answering again. + check(preferences.edit().remove(packageName).putBoolean(legacyMigratedKey(packageName), true).commit()) { "Unable to clear the client server region" } } @@ -52,6 +62,12 @@ internal class ClientServerRegionPreferences(context: Context) { internal companion object { const val PREFERENCES = "capture_server_regions" + private fun legacyMigratedKey(packageName: String) = "legacy_migrated.$packageName" + + /** The legacy global region when [packageName]'s publisher runs it; otherwise nothing. */ + fun legacySeed(legacy: GameServerRegion, packageName: String): GameServerRegion? = + legacy.takeIf { it in allowedFor(packageName) } + fun allowedFor(packageName: String): List = when (packageName) { SupportedGamePackages.HAOPLAY -> listOf( GameServerRegion.HAOPLAY_GLOBAL, diff --git a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt index bc02289..9d70a8f 100644 --- a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt @@ -2,6 +2,7 @@ package dev.gf2log.app.settings import android.content.Context import android.content.SharedPreferences +import android.util.Log import dev.gf2log.app.LanguagePreferences import dev.gf2log.app.TargetPackagePreferences import dev.gf2log.protocol.PayloadCatalog @@ -9,6 +10,7 @@ import java.time.ZoneId internal object UserSettingsPreferences { private const val PREFERENCES = "user_settings" + private const val TAG = "GF2Settings" private const val SCHEMA_VERSION = 4 private const val LEGACY_UNIFIED_SCHEMA_VERSION = 1 private const val KEY_SCHEMA_VERSION = "schema_version" @@ -146,8 +148,23 @@ internal object UserSettingsPreferences { editor.apply() } - private fun preferencesLocked(context: Context): SharedPreferences { - val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + private fun preferencesLocked(context: Context): SharedPreferences = migratedLocked( + preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE), + readLegacy = { readLegacy(context) }, + notPersisted = { Log.w(TAG, "Settings migration not persisted; will retry") }, + ) + + /** + * Brings [preferences] to the current schema. A commit that cannot reach the disk, such as on + * a full device, has still updated the in-memory values, so this process reads the migrated + * settings and the next one migrates again from the untouched sources instead of crashing at + * every cold start. [replace] keeps its strict check: a user's explicit change must persist. + */ + internal fun migratedLocked( + preferences: SharedPreferences, + readLegacy: () -> AppBackupSettings, + notPersisted: () -> Unit, + ): SharedPreferences { val storedVersion = preferences.getInt(KEY_SCHEMA_VERSION, 0) if (storedVersion == SCHEMA_VERSION) return preferences val migrated = if (storedVersion in LEGACY_UNIFIED_SCHEMA_VERSION until SCHEMA_VERSION) { @@ -163,11 +180,9 @@ internal object UserSettingsPreferences { .orEmpty(), ) } else { - readLegacy(context) - } - check(writeSettings(preferences.edit().clear(), migrated).commit()) { - "Unable to migrate app settings" + readLegacy() } + if (!writeSettings(preferences.edit().clear(), migrated).commit()) notPersisted() return preferences } diff --git a/app/src/test/java/dev/gf2log/app/ResetRegionChoicesTest.kt b/app/src/test/java/dev/gf2log/app/ResetRegionChoicesTest.kt new file mode 100644 index 0000000..65e36f2 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/ResetRegionChoicesTest.kt @@ -0,0 +1,29 @@ +package dev.gf2log.app + +import dev.gf2log.app.settings.GameServerRegion +import org.junit.Assert.assertEquals +import org.junit.Test + +class ResetRegionChoicesTest { + @Test + fun `a Platoon is offered only its own publisher's servers`() { + assertEquals( + listOf( + GameServerRegion.HAOPLAY_GLOBAL, + GameServerRegion.HAOPLAY_JAPAN, + GameServerRegion.HAOPLAY_KOREA, + GameServerRegion.HAOPLAY_ASIA, + ), + resetRegionChoices(SupportedGamePackages.HAOPLAY), + ) + assertEquals( + listOf(GameServerRegion.DARKWINTER_GLOBAL, GameServerRegion.DARKWINTER_CHINA), + resetRegionChoices(SupportedGamePackages.DARKWINTER), + ) + } + + @Test + fun `an unknown profile keeps every non-manual region`() { + assertEquals(GameServerRegion.entries.filterNot { it == GameServerRegion.MANUAL }, resetRegionChoices(null)) + } +} diff --git a/app/src/test/java/dev/gf2log/app/settings/ScopedResetRegionPolicyTest.kt b/app/src/test/java/dev/gf2log/app/settings/ScopedResetRegionPolicyTest.kt new file mode 100644 index 0000000..985048b --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/settings/ScopedResetRegionPolicyTest.kt @@ -0,0 +1,69 @@ +package dev.gf2log.app.settings + +import dev.gf2log.app.SupportedGamePackages +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScopedResetRegionPolicyTest { + private val haoPlay = ClientServerRegionPreferences.allowedFor(SupportedGamePackages.HAOPLAY) + + @Test + fun `an exported automatic region replays as automatic`() { + assertTrue( + ScopedResetRegionPolicy.restoresAutomatic( + GameServerRegion.HAOPLAY_KOREA, + profileRegion = GameServerRegion.HAOPLAY_KOREA, + allowed = haoPlay, + ), + ) + } + + @Test + fun `a deliberate override of the same publisher stays explicit`() { + assertFalse( + ScopedResetRegionPolicy.restoresAutomatic( + GameServerRegion.HAOPLAY_GLOBAL, + profileRegion = GameServerRegion.HAOPLAY_KOREA, + allowed = haoPlay, + ), + ) + } + + @Test + fun `another publisher's region is never restored onto a Platoon`() { + assertTrue( + ScopedResetRegionPolicy.restoresAutomatic( + GameServerRegion.DARKWINTER_GLOBAL, + profileRegion = GameServerRegion.HAOPLAY_KOREA, + allowed = haoPlay, + ), + ) + } + + @Test + fun `manual and unknown-profile values keep the previous behaviour`() { + assertFalse( + ScopedResetRegionPolicy.restoresAutomatic( + GameServerRegion.MANUAL, + profileRegion = GameServerRegion.HAOPLAY_KOREA, + allowed = haoPlay, + ), + ) + assertFalse(ScopedResetRegionPolicy.restoresAutomatic(GameServerRegion.DARKWINTER_GLOBAL, null, null)) + } + + @Test + fun `the legacy global region seeds only its own publisher`() { + assertTrue( + ClientServerRegionPreferences.legacySeed(GameServerRegion.HAOPLAY_KOREA, SupportedGamePackages.HAOPLAY) == + GameServerRegion.HAOPLAY_KOREA, + ) + assertTrue( + ClientServerRegionPreferences.legacySeed(GameServerRegion.HAOPLAY_KOREA, SupportedGamePackages.DARKWINTER) == null, + ) + assertTrue( + ClientServerRegionPreferences.legacySeed(GameServerRegion.MANUAL, SupportedGamePackages.HAOPLAY) == null, + ) + } +} diff --git a/app/src/test/java/dev/gf2log/app/settings/UserSettingsMigrationTest.kt b/app/src/test/java/dev/gf2log/app/settings/UserSettingsMigrationTest.kt new file mode 100644 index 0000000..2421ca1 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/settings/UserSettingsMigrationTest.kt @@ -0,0 +1,98 @@ +package dev.gf2log.app.settings + +import android.content.SharedPreferences +import dev.gf2log.protocol.PayloadCatalog +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class UserSettingsMigrationTest { + @Test + fun `a migration that cannot reach the disk still serves the migrated settings`() { + val preferences = InMemoryPreferences(commitSucceeds = false) + var notices = 0 + var legacyReads = 0 + val legacy = { + legacyReads += 1 + settings(language = "ko") + } + + val first = UserSettingsPreferences.migratedLocked(preferences, legacy) { notices += 1 } + val second = UserSettingsPreferences.migratedLocked(preferences, legacy) { notices += 1 } + + assertSame(preferences, first) + assertSame(preferences, second) + assertEquals("ko", preferences.getString("language", null)) + assertEquals("ko", second.getString("language", null)) + // The in-memory copy is current, so this process migrates once; the next process retries. + assertEquals(1, notices) + assertEquals(1, legacyReads) + } + + @Test + fun `a persisted migration reports nothing`() { + val preferences = InMemoryPreferences(commitSucceeds = true) + var notices = 0 + + UserSettingsPreferences.migratedLocked(preferences, { settings(language = "en") }) { notices += 1 } + + assertEquals(0, notices) + assertEquals(4, preferences.getInt("schema_version", 0)) + } + + private fun settings(language: String) = AppBackupSettings( + language = language, + themeMode = "system", + gameServerRegion = GameServerRegion.MANUAL.storedValue, + gameTimeZoneId = "UTC", + onboardingCompleted = false, + detailedNotifications = true, + targetPackage = dev.gf2log.app.TargetPackagePreferences.DEFAULT_TARGET_PACKAGE, + payloadHistory = PayloadCatalog.categories.associate { it.payloadType to it.isRequired }, + memberOrder = emptyList(), + weeklyCutlines = WeeklyCutlines(), + ) + + /** SharedPreferences whose commit updates memory first and can then fail, like a full disk. */ + private class InMemoryPreferences(private val commitSucceeds: Boolean) : SharedPreferences { + private val values = HashMap() + + override fun getAll(): Map = HashMap(values) + override fun getString(key: String, defValue: String?) = values[key] as String? ?: defValue + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String, defValues: Set?) = values[key] as Set? ?: defValues + override fun getInt(key: String, defValue: Int) = values[key] as Int? ?: defValue + override fun getLong(key: String, defValue: Long) = values[key] as Long? ?: defValue + override fun getFloat(key: String, defValue: Float) = values[key] as Float? ?: defValue + override fun getBoolean(key: String, defValue: Boolean) = values[key] as Boolean? ?: defValue + override fun contains(key: String) = key in values + override fun edit(): SharedPreferences.Editor = Editor() + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) = Unit + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) = Unit + + private inner class Editor : SharedPreferences.Editor { + private val pending = HashMap() + private val removed = HashSet() + private var clear = false + + override fun putString(key: String, value: String?) = apply { pending[key] = value } + override fun putStringSet(key: String, values: Set?) = apply { pending[key] = values } + override fun putInt(key: String, value: Int) = apply { pending[key] = value } + override fun putLong(key: String, value: Long) = apply { pending[key] = value } + override fun putFloat(key: String, value: Float) = apply { pending[key] = value } + override fun putBoolean(key: String, value: Boolean) = apply { pending[key] = value } + override fun remove(key: String) = apply { removed += key } + override fun clear() = apply { clear = true } + override fun commit(): Boolean { + apply() + return commitSucceeds + } + + override fun apply() { + if (clear) values.clear() + removed.forEach(values::remove) + values.putAll(pending) + } + } + } +} From 2b1a083205272bc8a4917b12684a643889ab4d58 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:01:12 +0900 Subject: [PATCH 09/98] Leave minimum and unknown values empty in the weekly cell editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field is prefilled only with an exact value or an unsaved draft, and the captured notation (≥90, ?) becomes its hint, so typing the shown number confirms it instead of being dropped as unchanged. Fixes q-uiux-2. --- .../main/java/dev/gf2log/app/WeeklyDialogs.kt | 60 ++++++++-- app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../dev/gf2log/app/WeeklyCellEditorTest.kt | 106 ++++++++++++++++++ 4 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/WeeklyCellEditorTest.kt diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt index 89d7e96..53094e4 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -8,6 +8,7 @@ import android.view.View import dev.gf2log.app.management.MetricCertainty import dev.gf2log.app.management.WeeklyEvidenceAnalyzer import dev.gf2log.app.management.WeeklyEvidenceAnalyzer.Metric +import dev.gf2log.app.management.WeeklyMetricPresentation import dev.gf2log.app.management.WeeklyOverrideEditing import dev.gf2log.app.management.WeeklyOverrideEditing.Choice import dev.gf2log.app.management.WeeklyReportBuilder @@ -72,8 +73,8 @@ internal object WeeklyDialogs { } /** - * Edits one member-day. Fields start from the displayed values; only fields the user changes - * become edits, so untouched derived values keep their certainty. + * Edits one member-day. Exact values start filled in; only fields the user changes become + * edits, so untouched derived values keep their certainty. */ fun showEdit( activity: Activity, @@ -85,18 +86,22 @@ internal object WeeklyDialogs { next: (() -> Unit)? = null, onDone: (WeeklyOverrideEditing.CellEdit) -> Unit, ) { - fun numberField(label: Int, initial: String, hint: Int): Pair { - val field = activity.textField(activity.getString(hint), inputType = InputType.TYPE_CLASS_NUMBER).apply { setText(initial) } - return LabeledField(activity, activity.getString(label), field) to initial + // A minimum or unknown value starts empty with its notation as the hint, so typing the + // shown number confirms it instead of reading as "unchanged". + fun numberField(label: Int, field: WeeklyCellEditor.Field, hint: Int): Pair { + val start = WeeklyCellEditor.start(cell, current, field) + val hintText = start.notation?.let { activity.getString(R.string.captured_value_hint, it) } ?: activity.getString(hint) + val input = activity.textField(hintText, inputType = InputType.TYPE_CLASS_NUMBER).apply { setText(start.text) } + return LabeledField(activity, activity.getString(label), input) to start.text } - val (merit, meritInitial) = numberField(R.string.merit_short, current?.merit ?: cell.meritDelta?.toString().orEmpty(), R.string.blank_clears) + val (merit, meritInitial) = numberField(R.string.merit_short, WeeklyCellEditor.Field.MERIT, R.string.blank_clears) val score = if (isGunsmokeWeek) { - numberField(R.string.point_short, current?.score ?: cell.scoreDelta?.toString().orEmpty(), R.string.blank_clears) + numberField(R.string.point_short, WeeklyCellEditor.Field.SCORE, R.string.blank_clears) } else { null } val attempts = if (isGunsmokeWeek) { - numberField(R.string.attempt_short, current?.attempts ?: cell.attempts?.toString().orEmpty(), R.string.attempts_range) + numberField(R.string.attempt_short, WeeklyCellEditor.Field.ATTEMPTS, R.string.attempts_range) } else { null } @@ -129,7 +134,7 @@ internal object WeeklyDialogs { if (next != null) builder.setNeutralButton(R.string.apply_and_next_day, null) Dialogs.show(builder) { dialog -> fun changed(field: Pair?): String? = - field?.let { (view, initial) -> view.input.text.toString().takeIf { it != initial } } + field?.let { (view, initial) -> WeeklyCellEditor.changed(view.input.text.toString(), initial) } fun finish(edit: WeeklyOverrideEditing.CellEdit, then: (() -> Unit)? = null) { val probe = WeeklyOverrideEditing.merge( cell.manualOverride?.periodStart ?: cell.gameDay, @@ -288,3 +293,40 @@ internal object WeeklyDialogs { WeeklyEvidenceAnalyzer.Fact.AMBIGUOUS_ALLOCATION -> R.string.evidence_fact_ambiguous } } + +/** The pure part of the cell editor: what each number field starts with and what counts as a change. */ +internal object WeeklyCellEditor { + enum class Field { MERIT, SCORE, ATTEMPTS } + + /** + * [text] fills the field; [notation] is the displayed value (`≥90`, `?`) shown as a hint when + * the field starts empty because that value is not exact. + */ + data class Start(val text: String, val notation: String? = null) + + /** + * A field is filled in only with an unsaved draft value or an exact value (a manual override + * is exact). Prefilling a minimum or an unknown would make the confirmed number impossible to + * enter, because typing the same digits leaves the field "unchanged". + */ + fun start(cell: WeeklyReportBuilder.DayCell, current: WeeklyOverrideEditing.CellEdit?, field: Field): Start { + val draft = when (field) { + Field.MERIT -> current?.merit + Field.SCORE -> current?.score + Field.ATTEMPTS -> current?.attempts + } + if (draft != null) return Start(draft) + val (value, certainty) = when (field) { + Field.MERIT -> cell.meritDelta to cell.meritCertainty + Field.SCORE -> cell.scoreDelta to cell.scoreCertainty + Field.ATTEMPTS -> cell.attempts to cell.attemptsCertainty + } + if (value != null && certainty == MetricCertainty.EXACT) return Start(value.toString()) + // Same notation as the table cell, including its "-" for attempts on a manual cell. + val missing = if (field == Field.ATTEMPTS && cell.manualOverride != null) "-" else "?" + return Start("", WeeklyMetricPresentation.format(value, certainty, missing)) + } + + /** The typed text when it differs from what the field started with, otherwise null. */ + fun changed(input: String, start: String): String? = input.takeIf { it != start } +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fb3f4ec..e47085c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -453,6 +453,7 @@ 0–3 · 비우면 해제 커트라인 미달 비우면 해제 + 캡처: %1$s 수정 취소 자동 수동 값 지우기 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef8188d..1f8ce94 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -459,6 +459,7 @@ 0–3 · blank clears below cutline Leave blank to clear + Captured: %1$s Cancel editing Auto Clear manual values diff --git a/app/src/test/java/dev/gf2log/app/WeeklyCellEditorTest.kt b/app/src/test/java/dev/gf2log/app/WeeklyCellEditorTest.kt new file mode 100644 index 0000000..9fc625b --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/WeeklyCellEditorTest.kt @@ -0,0 +1,106 @@ +package dev.gf2log.app + +import dev.gf2log.app.WeeklyCellEditor.Field +import dev.gf2log.app.management.DailyEvidence +import dev.gf2log.app.management.MetricCertainty +import dev.gf2log.app.management.WeeklyCellOverride +import dev.gf2log.app.management.WeeklyOverrideEditing.CellEdit +import dev.gf2log.app.management.WeeklyReportBuilder.DayCell +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class WeeklyCellEditorTest { + private val day = LocalDate.of(2026, 10, 14) + + @Test + fun `a minimum starts empty so typing the shown number confirms it`() { + val cell = DayCell(day, meritDelta = 90, scoreDelta = null, inference = null, evidence = DailyEvidence.PARTIAL_DAY) + assertEquals(MetricCertainty.LOWER_BOUND, cell.meritCertainty) + + val start = WeeklyCellEditor.start(cell, null, Field.MERIT) + + assertEquals("", start.text) + assertEquals("≥90", start.notation) + assertEquals("90", WeeklyCellEditor.changed("90", start.text)) + } + + @Test + fun `an unknown value starts empty with the question mark as its hint`() { + val cell = DayCell(day, meritDelta = 40, scoreDelta = null, inference = null, evidence = DailyEvidence.SPARSE_INFERRED) + assertEquals(MetricCertainty.UNKNOWN, cell.meritCertainty) + + val start = WeeklyCellEditor.start(cell, null, Field.MERIT) + + assertEquals(WeeklyCellEditor.Start("", "?"), start) + assertEquals("40", WeeklyCellEditor.changed("40", start.text)) + } + + @Test + fun `an exact value is prefilled and retyping it is no edit`() { + val cell = DayCell(day, meritDelta = 90, scoreDelta = null, inference = null, evidence = DailyEvidence.ATTRIBUTED) + + val start = WeeklyCellEditor.start(cell, null, Field.MERIT) + + assertEquals(WeeklyCellEditor.Start("90"), start) + assertNull(WeeklyCellEditor.changed("90", start.text)) + assertEquals("", WeeklyCellEditor.changed("", start.text)) + } + + @Test + fun `a lower-bound score and attempt count start empty`() { + val cell = DayCell( + day, + meritDelta = null, + scoreDelta = 31_635, + inference = null, + evidence = DailyEvidence.PARTIAL_DAY, + isGunsmokeWeek = true, + solvedAttempts = 2, + solvedScoreCertainty = MetricCertainty.LOWER_BOUND, + solvedAttemptsCertainty = MetricCertainty.LOWER_BOUND, + ) + + assertEquals(WeeklyCellEditor.Start("", "≥31635"), WeeklyCellEditor.start(cell, null, Field.SCORE)) + assertEquals(WeeklyCellEditor.Start("", "≥2"), WeeklyCellEditor.start(cell, null, Field.ATTEMPTS)) + } + + @Test + fun `a manual override is exact and prefilled`() { + val override = WeeklyCellOverride( + uid = 1, + periodStart = day.minusDays(2), + gameDay = day, + meritDelta = 75, + scoreDelta = null, + attempts = null, + attended = null, + dailyPatrol = null, + ) + val cell = DayCell( + day, + meritDelta = 75, + scoreDelta = null, + inference = null, + evidence = DailyEvidence.MANUAL, + manualOverride = override, + isGunsmokeWeek = true, + ) + + assertEquals(WeeklyCellEditor.Start("75"), WeeklyCellEditor.start(cell, null, Field.MERIT)) + // A manual cell without attempts shows "-", as the table does. + assertEquals(WeeklyCellEditor.Start("", "-"), WeeklyCellEditor.start(cell, null, Field.ATTEMPTS)) + } + + @Test + fun `an unsaved draft value wins over the captured value`() { + val cell = DayCell(day, meritDelta = 90, scoreDelta = null, inference = null, evidence = DailyEvidence.PARTIAL_DAY) + val draft = CellEdit(uid = 1, gameDay = day, merit = "120") + + val start = WeeklyCellEditor.start(cell, draft, Field.MERIT) + + assertEquals(WeeklyCellEditor.Start("120"), start) + assertNull(WeeklyCellEditor.changed("120", start.text)) + } +} From c0e3723a7fe5ba6cad2407d4b6968ff27c6887d3 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:01:22 +0900 Subject: [PATCH 10/98] Show and speak every weekly cutline warning and keep large-font values visible Login, patrol, score and attempt warnings are now underlined and announced as below cutline, not only colored. The Gunsmoke score and attempts line may wrap to a second line at large font sizes instead of being clipped, the UID line ellipsizes, and the evidence sheet speaks values the way the cell does. Fixes ui-a11y-fontscale-1, ui-a11y-fontscale-3, ui-a11y-fontscale-9. --- .../app/WeeklyTableCellIntegrationTest.kt | 138 ++++++++++++++++++ .../main/java/dev/gf2log/app/WeeklyDialogs.kt | 20 ++- .../main/java/dev/gf2log/app/WeeklyTable.kt | 89 ++++++++--- .../gf2log/app/WeeklyTableCountMarkTest.kt | 37 +++++ 4 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 app/src/androidTest/java/dev/gf2log/app/WeeklyTableCellIntegrationTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/WeeklyTableCountMarkTest.kt diff --git a/app/src/androidTest/java/dev/gf2log/app/WeeklyTableCellIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/WeeklyTableCellIntegrationTest.kt new file mode 100644 index 0000000..acb1e4d --- /dev/null +++ b/app/src/androidTest/java/dev/gf2log/app/WeeklyTableCellIntegrationTest.kt @@ -0,0 +1,138 @@ +package dev.gf2log.app + +import android.content.Context +import android.content.res.Configuration +import android.text.Spanned +import android.text.style.UnderlineSpan +import android.view.View +import android.view.ViewGroup +import android.widget.HorizontalScrollView +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.gf2log.app.management.DailyEvidence +import dev.gf2log.app.management.MetricCertainty +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.settings.WeeklyCutlines +import java.time.LocalDate +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WeeklyTableCellIntegrationTest { + private val base = ApplicationProvider.getApplicationContext() + private val week = LocalDate.of(2026, 10, 12) + + @Test + fun gunsmokeAttemptsStayVisibleAtTwiceTheFontSize() { + val context = scaled(2f) + val table = WeeklyTable(context, isGunsmokeWeek = true, zone = ZoneOffset.UTC, cutlines = WeeklyCutlines()) + val row = table.MemberRow() + row.bind(member(), rankValue = 1, edits = emptyMap()) {} + measure(row.view, context, table.rowHeight) + + val caption = cells(row.view).first().getChildAt(1) as TextView + val layout = caption.layout + val last = layout.lineCount - 1 + assertTrue(layout.lineCount <= caption.maxLines) + assertEquals(0, layout.getEllipsisCount(last)) + assertEquals(caption.text.length, layout.getLineEnd(last)) + assertTrue(caption.text.toString().endsWith("≥2")) + } + + @Test + fun cutlineWarningsAreUnderlinedAndSpoken() { + val cutlines = WeeklyCutlines(dailyGunsmokeScore = 40_000, weeklyLoginDays = 5) + val table = WeeklyTable(base, isGunsmokeWeek = true, zone = ZoneOffset.UTC, cutlines = cutlines) + val row = table.MemberRow() + row.bind(member(), rankValue = 1, edits = emptyMap()) {} + val below = base.getString(R.string.below_cutline) + + val day = cells(row.view).first() + val score = base.getString(R.string.point_short) + " " + base.getString(R.string.at_least, "31635") + ", " + below + assertTrue(day.contentDescription.toString(), day.contentDescription.contains(score)) + val caption = day.getChildAt(1) as TextView + assertTrue(underlined(caption.text as Spanned, "≥31635")) + assertFalse(underlined(caption.text as Spanned, "≥2")) + + val total = cells(row.view).last() + val login = base.getString(R.string.login_short) + " 3, " + below + assertTrue(total.contentDescription.toString(), total.contentDescription.contains(login)) + val activity = total.getChildAt(2) as TextView + assertTrue(underlined(activity.text as Spanned, "3")) + } + + @Test + fun evidenceValuesSpeakLikeTheCell() { + val no = CellSpeech.mark(base, CellDisplay.Mark("×", R.color.failure_mark, R.string.no)) + + assertEquals(base.getString(R.string.no), no) + assertFalse(no.contains("×")) + assertEquals(base.getString(R.string.at_least, "90"), CellSpeech.value(base, "≥90")) + assertEquals(base.getString(R.string.evidence_unknown_short), CellSpeech.value(base, "?")) + } + + private fun member(): WeeklyReportBuilder.MemberRow { + val days = (0L until 7L).map { offset -> + WeeklyReportBuilder.DayCell( + gameDay = week.plusDays(offset), + meritDelta = 90, + scoreDelta = 31_635, + inference = null, + evidence = DailyEvidence.PARTIAL_DAY, + isGunsmokeWeek = true, + solvedAttempts = 2, + solvedScoreCertainty = MetricCertainty.LOWER_BOUND, + solvedAttemptsCertainty = MetricCertainty.LOWER_BOUND, + ) + } + return WeeklyReportBuilder.MemberRow( + uid = 123_456_789, + name = "Member", + days = days, + totalMerit = 630, + totalScore = 221_445, + isGunsmokeWeek = true, + resolvedGunsmokeTotals = WeeklyReportBuilder.ResolvedGunsmokeTotals( + merit = 630, + meritCertainty = MetricCertainty.EXACT, + attempts = 14, + attemptsCertainty = MetricCertainty.EXACT, + loginDays = 3, + loginDaysCertainty = MetricCertainty.EXACT, + patrolDays = 3, + patrolDaysCertainty = MetricCertainty.EXACT, + ), + ) + } + + private fun scaled(fontScale: Float): Context = + base.createConfigurationContext(Configuration(base.resources.configuration).apply { this.fontScale = fontScale }) + + private fun measure(view: View, context: Context, height: Int) { + val width = (411 * context.resources.displayMetrics.density).toInt() + view.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + view.layout(0, 0, width, height) + } + + /** The daily cells and the weekly total, in order, from one grid row. */ + private fun cells(row: View): List { + val scroller = (row as ViewGroup).getChildAt(1) as HorizontalScrollView + val strip = scroller.getChildAt(0) as ViewGroup + return (0 until strip.childCount).map { strip.getChildAt(it) as ViewGroup } + } + + private fun underlined(text: Spanned, value: String): Boolean { + val start = text.toString().indexOf(value) + return start >= 0 && text.getSpans(start, start + value.length, UnderlineSpan::class.java).any { span -> + text.getSpanStart(span) <= start && text.getSpanEnd(span) >= start + value.length + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt index 53094e4..f2ddad5 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -41,22 +41,28 @@ internal object WeeklyDialogs { display: CellDisplay, explanations: Map, ) { + // Each value carries the words the table cell speaks for it, never the glyph's name. val metrics = buildList { - add(Metric.MERIT to display.merit) + add(Triple(Metric.MERIT, display.merit, CellSpeech.value(activity, display.merit))) if (isGunsmokeWeek) { - add(Metric.SCORE to (display.score ?: "-")) - add(Metric.ATTEMPTS to (display.attempts ?: "-")) + val score = display.score ?: "-" + val attempts = display.attempts ?: "-" + add(Triple(Metric.SCORE, score, CellSpeech.value(activity, score))) + add(Triple(Metric.ATTEMPTS, attempts, CellSpeech.value(activity, attempts))) } - add(Metric.LOGIN to display.login.symbol) - add(Metric.DAILY_PATROL to display.patrol.symbol) + add(Triple(Metric.LOGIN, display.login.symbol, CellSpeech.mark(activity, display.login))) + add(Triple(Metric.DAILY_PATROL, display.patrol.symbol, CellSpeech.mark(activity, display.patrol))) } val content = Dialogs.content(activity) { - metrics.forEachIndexed { index, (metric, value) -> + metrics.forEachIndexed { index, (metric, value, spoken) -> val explanation = explanations[metric] ?: return@forEachIndexed if (index > 0) addView(activity.divider(), activity.lp(top = 12, bottom = 12)) addView(activity.row { addView(activity.text(metricLabel(activity, metric), TextRole.SUBTITLE), activity.lp(width = 0, weight = 1f)) - addView(activity.text(value, TextRole.TITLE) { gravity = Gravity.END }, activity.lp(WRAP, WRAP, start = 8)) + addView(activity.text(value, TextRole.TITLE) { + gravity = Gravity.END + contentDescription = spoken + }, activity.lp(WRAP, WRAP, start = 8)) }, activity.lp()) addView(certaintyBadge(activity, explanation.certainty), activity.lp(WRAP, WRAP, top = 4)) explanation.facts.ifEmpty { listOf(WeeklyEvidenceAnalyzer.Fact.NO_OBSERVATION) }.forEach { fact -> diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt index a43b6c5..b6377db 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt @@ -97,7 +97,19 @@ internal data class CellDisplay( val patrol: Mark, val edited: Boolean, ) { - data class Mark(val symbol: String, @ColorRes val color: Int, val spoken: Int? = null) + /** [warn] marks a weekly count below its cutline; it is drawn underlined and spoken, not only colored. */ + data class Mark(val symbol: String, @ColorRes val color: Int, val spoken: Int? = null, val warn: Boolean = false) +} + +/** How TalkBack reads a displayed weekly value; shared by the grid and the evidence sheet. */ +internal object CellSpeech { + fun mark(context: Context, mark: CellDisplay.Mark): String = mark.spoken?.let(context::getString) ?: value(context, mark.symbol) + + fun value(context: Context, value: String): String = when { + value == "?" -> context.getString(R.string.evidence_unknown_short) + value.startsWith("≥") -> context.getString(R.string.at_least, value.drop(1)) + else -> value + } } /** @@ -148,7 +160,10 @@ internal class WeeklyTable( maxLines = 2 ellipsize = TextUtils.TruncateAt.END } - private val detail = context.text(null, TextRole.CAPTION) { maxLines = 1 } + private val detail = context.text(null, TextRole.CAPTION) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } private val rankChip = GradientDrawable().apply { cornerRadius = context.dpf(8f) } private val rank = context.text(null, TextRole.LABEL) { gravity = Gravity.CENTER @@ -208,14 +223,18 @@ internal class WeeklyTable( gravity = Gravity.CENTER maxLines = 1 } + // Widths stop growing at 1.5x font while text keeps scaling, so "score · attempts" may + // wrap; the row height grows with the full font scale and has room for the second line. private val gunsmoke = context.text(null, TextRole.CAPTION) { gravity = Gravity.CENTER - maxLines = 1 + maxLines = GUNSMOKE_LINES + ellipsize = TextUtils.TruncateAt.END visibility = if (isGunsmokeWeek) View.VISIBLE else View.GONE } private val activity = context.text(null, TextRole.CAPTION) { gravity = Gravity.CENTER maxLines = 1 + ellipsize = TextUtils.TruncateAt.END } val view: LinearLayout = context.column { gravity = Gravity.CENTER @@ -244,10 +263,12 @@ internal class WeeklyTable( merit.paintFlags = if (display.meritWarn) merit.paintFlags or android.graphics.Paint.UNDERLINE_TEXT_FLAG else merit.paintFlags and android.graphics.Paint.UNDERLINE_TEXT_FLAG.inv() if (isGunsmokeWeek) { - gunsmoke.text = "${display.score ?: "-"} · ${display.attempts ?: "-"}" - gunsmoke.setTextColor( - context.getColor(if (display.scoreWarn || display.attemptsWarn) R.color.cutline_mark else R.color.text_secondary), - ) + // Each value below its cutline is colored and underlined on its own, like merit. + gunsmoke.text = android.text.SpannableStringBuilder().apply { + appendValue(display.score ?: "-", display.scoreWarn) + append(" · ") + appendValue(display.attempts ?: "-", display.attemptsWarn) + } } activity.text = android.text.SpannableStringBuilder().apply { appendMark(context.getString(R.string.login_initial), display.login) @@ -258,7 +279,8 @@ internal class WeeklyTable( fill.color = context.getColor( when { display.edited -> R.color.accent_surface - display.meritWarn || display.scoreWarn || display.attemptsWarn -> R.color.warning_surface + display.meritWarn || display.scoreWarn || display.attemptsWarn || + display.login.warn || display.patrol.warn -> R.color.warning_surface total -> R.color.table_header else -> R.color.table_cell }, @@ -270,35 +292,56 @@ internal class WeeklyTable( if (display.meritWarn) append(", ").append(context.getString(R.string.below_cutline)) if (isGunsmokeWeek) { append(". ").append(context.getString(R.string.point_short)).append(' ').append(spoken(display.score ?: "-")) + if (display.scoreWarn) append(", ").append(context.getString(R.string.below_cutline)) append(". ").append(context.getString(R.string.attempt_short)).append(' ').append(spoken(display.attempts ?: "-")) + if (display.attemptsWarn) append(", ").append(context.getString(R.string.below_cutline)) } append(". ").append(context.getString(R.string.login_short)).append(' ').append(spoken(display.login)) + if (display.login.warn) append(", ").append(context.getString(R.string.below_cutline)) append(". ").append(context.getString(R.string.patrol_short)).append(' ').append(spoken(display.patrol)) + if (display.patrol.warn) append(", ").append(context.getString(R.string.below_cutline)) if (display.edited) append(". ").append(context.getString(R.string.edited)) } view.setOnClickListener { onClick() } } + private fun android.text.SpannableStringBuilder.appendValue(value: String, warn: Boolean) { + val start = length + append(value) + if (warn) underline(start, R.color.cutline_mark) + } + private fun android.text.SpannableStringBuilder.appendMark(label: String, mark: CellDisplay.Mark) { append(label) val start = length append(mark.symbol) + if (mark.warn) { + underline(start, mark.color) + } else { + setSpan( + android.text.style.ForegroundColorSpan(context.getColor(mark.color)), + start, + length, + android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + + /** Colors and underlines from [start] to the end, so a warning never relies on color alone. */ + private fun android.text.SpannableStringBuilder.underline(start: Int, @ColorRes color: Int) { setSpan( - android.text.style.ForegroundColorSpan(context.getColor(mark.color)), + android.text.style.ForegroundColorSpan(context.getColor(color)), start, length, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, ) + setSpan(android.text.style.UnderlineSpan(), start, length, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } } - private fun spoken(mark: CellDisplay.Mark): String = mark.spoken?.let(context::getString) ?: spoken(mark.symbol) + private fun spoken(mark: CellDisplay.Mark): String = CellSpeech.mark(context, mark) - private fun spoken(value: String): String = when { - value == "?" -> context.getString(R.string.evidence_unknown_short) - value.startsWith("≥") -> context.getString(R.string.at_least, value.drop(1)) - else -> value - } + private fun spoken(value: String): String = CellSpeech.value(context, value) fun daily(cell: WeeklyReportBuilder.DayCell, edit: WeeklyOverrideEditing.CellEdit? = null): CellDisplay { val dayClosed = !PlatoonPeriods.periodStartInstant(cell.gameDay.plusDays(1), zone).isAfter(now) @@ -359,11 +402,6 @@ internal class WeeklyTable( edited = false, ) - private fun countMark(value: Int?, certainty: MetricCertainty, below: (Int) -> Boolean) = CellDisplay.Mark( - WeeklyMetricPresentation.format(value, certainty), - if (WeeklyMetricPresentation.warnsBelowCutline(value, certainty, below)) R.color.cutline_mark else R.color.text_secondary, - ) - private fun activityMark(value: Boolean?, observed: Boolean, dayClosed: Boolean): CellDisplay.Mark = when { value == true -> YES_MARK !observed -> NONE_MARK @@ -428,6 +466,17 @@ internal class WeeklyTable( const val STANDARD_ROW_HEIGHT = 64 const val GUNSMOKE_ROW_HEIGHT = 80 const val PODIUM = 3 + const val GUNSMOKE_LINES = 2 + + /** A weekly login or patrol count; [CellDisplay.Mark.warn] is set while it is below its cutline. */ + fun countMark(value: Int?, certainty: MetricCertainty, below: (Int) -> Boolean): CellDisplay.Mark { + val warn = WeeklyMetricPresentation.warnsBelowCutline(value, certainty, below) + return CellDisplay.Mark( + WeeklyMetricPresentation.format(value, certainty), + if (warn) R.color.cutline_mark else R.color.text_secondary, + warn = warn, + ) + } private val YES_MARK = CellDisplay.Mark("✓", R.color.success_mark, R.string.yes) private val NO_MARK = CellDisplay.Mark("×", R.color.failure_mark, R.string.no) diff --git a/app/src/test/java/dev/gf2log/app/WeeklyTableCountMarkTest.kt b/app/src/test/java/dev/gf2log/app/WeeklyTableCountMarkTest.kt new file mode 100644 index 0000000..c4fcf6e --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/WeeklyTableCountMarkTest.kt @@ -0,0 +1,37 @@ +package dev.gf2log.app + +import dev.gf2log.app.management.MetricCertainty +import dev.gf2log.app.settings.WeeklyCutlines +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WeeklyTableCountMarkTest { + private val cutlines = WeeklyCutlines(weeklyLoginDays = 5, weeklyPatrolDays = 4) + + @Test + fun `a login count below its cutline carries a warning beyond color`() { + val mark = WeeklyTable.countMark(3, MetricCertainty.EXACT, cutlines::belowWeeklyLoginDays) + + assertTrue(mark.warn) + assertEquals("3", mark.symbol) + assertEquals(R.color.cutline_mark, mark.color) + } + + @Test + fun `a confirmed minimum below the cutline warns too`() { + assertTrue(WeeklyTable.countMark(2, MetricCertainty.LOWER_BOUND, cutlines::belowWeeklyPatrolDays).warn) + } + + @Test + fun `counts that meet the cutline or are unknown do not warn`() { + assertFalse(WeeklyTable.countMark(5, MetricCertainty.EXACT, cutlines::belowWeeklyLoginDays).warn) + assertFalse(WeeklyTable.countMark(1, MetricCertainty.UNKNOWN, cutlines::belowWeeklyLoginDays).warn) + assertFalse(WeeklyTable.countMark(null, MetricCertainty.UNKNOWN, cutlines::belowWeeklyLoginDays).warn) + assertEquals( + R.color.text_secondary, + WeeklyTable.countMark(6, MetricCertainty.EXACT, cutlines::belowWeeklyLoginDays).color, + ) + } +} From 264d12d618bb251ea9ac2a1a164f374db31cd768 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:01:39 +0900 Subject: [PATCH 11/98] Report a weekly note deletion that fails The deletion now shows a failure message and reloads the list when the repository returns false or throws. Fixes q-uiux-13. --- .../dev/gf2log/app/WeeklyJournalSection.kt | 17 +++++++++------- app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../gf2log/app/WeeklyJournalDeletionTest.kt | 20 +++++++++++++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/WeeklyJournalDeletionTest.kt diff --git a/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt b/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt index a2afeb2..b59bf42 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt @@ -258,17 +258,20 @@ internal class WeeklyJournalSection( activity.getString(R.string.delete), destructive = true, ) { + // A failure is reported too, and the list reloads so it shows what is actually stored. work.run(task = { repository().deleteWeeklyNote(note.id) }) { result -> - if (result.getOrDefault(false)) { - Feedback.show(activity, R.string.note_deleted) - onChanged() - } + Feedback.show(activity, deletionFeedback(result)) + onChanged() } } } - private companion object { - const val STATE_NOTE_DAY = "weekly.note_day" - const val STATE_NOTE_DRAFT = "weekly.note_draft" + internal companion object { + private const val STATE_NOTE_DAY = "weekly.note_day" + private const val STATE_NOTE_DRAFT = "weekly.note_draft" + + /** The message for a note deletion: only a stored `true` counts as deleted. */ + fun deletionFeedback(result: Result): Int = + if (result.getOrDefault(false)) R.string.note_deleted else R.string.note_delete_failed } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e47085c..757321c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -147,6 +147,7 @@ 비고 삭제 삭제 비고를 삭제했습니다 + 비고를 삭제할 수 없습니다 주간 CSV 내보내기 주간 CSV를 내보냈습니다 주간 CSV를 내보낼 수 없습니다 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f8ce94..5e5b493 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -147,6 +147,7 @@ Delete note Delete Note deleted + Unable to delete the note Export weekly CSV Weekly CSV exported Unable to export weekly CSV diff --git a/app/src/test/java/dev/gf2log/app/WeeklyJournalDeletionTest.kt b/app/src/test/java/dev/gf2log/app/WeeklyJournalDeletionTest.kt new file mode 100644 index 0000000..dc038ce --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/WeeklyJournalDeletionTest.kt @@ -0,0 +1,20 @@ +package dev.gf2log.app + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WeeklyJournalDeletionTest { + @Test + fun `only a stored deletion reports success`() { + assertEquals(R.string.note_deleted, WeeklyJournalSection.deletionFeedback(Result.success(true))) + } + + @Test + fun `a deletion that changed nothing or threw reports failure`() { + assertEquals(R.string.note_delete_failed, WeeklyJournalSection.deletionFeedback(Result.success(false))) + assertEquals( + R.string.note_delete_failed, + WeeklyJournalSection.deletionFeedback(Result.failure(IllegalStateException("disk I/O"))), + ) + } +} From 720fd92aa2c9f2e295f5608830fe1db45e50ac89 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:01:58 +0900 Subject: [PATCH 12/98] Save member order only on real changes and keep it consistent with storage A drag that ends where it started no longer turns the weekly ranking into a saved join-date order, the screen says when the tables are still ranked, and the reset wording no longer promises join-date order. Saving keeps the stored places of members who are not active. Reset reports a failed clear, and a failed commit of the member order or the cutlines puts the previous values back in memory so later reads match what was saved. Fixes input-settings-prefs-1, input-settings-prefs-2, input-settings-prefs-5, input-settings-prefs-7. --- .../dev/gf2log/app/MemberOrderActivity.kt | 38 ++++-- .../app/settings/MemberOrderPreferences.kt | 112 +++++++++++++++--- .../app/settings/WeeklyCutlinePreferences.kt | 30 ++++- app/src/main/res/values-ko/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- .../app/settings/FakeSharedPreferences.kt | 58 +++++++++ .../settings/MemberOrderPreferencesTest.kt | 103 ++++++++++++++++ .../settings/WeeklyCutlinePreferencesTest.kt | 46 +++++++ 8 files changed, 365 insertions(+), 32 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/settings/FakeSharedPreferences.kt create mode 100644 app/src/test/java/dev/gf2log/app/settings/MemberOrderPreferencesTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/settings/WeeklyCutlinePreferencesTest.kt diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index b709fa8..c4cf64e 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -23,6 +23,7 @@ import dev.gf2log.app.management.MemberSortField import dev.gf2log.app.management.MemberStatus import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotMember +import dev.gf2log.app.settings.MemberOrderMerge import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork @@ -60,7 +61,10 @@ class MemberOrderActivity : BaseActivity() { private val stateAdapter = SingleViewAdapter() private lateinit var touchHelper: ItemTouchHelper private var members = mutableListOf() + /** The order as loaded or last saved; a drag that ends where it started saves nothing. */ + private var shownOrder = emptyList() private var latestMembers = emptyMap() + private lateinit var rankedDefault: TextView private var sortField = MemberSortField.JOIN_DATE private var sortDirection = MemberSortDirection.ASCENDING private lateinit var fieldButton: TextView @@ -111,6 +115,9 @@ class MemberOrderActivity : BaseActivity() { private fun buildHeader(): View = column { addView(text(getString(R.string.member_order_description), TextRole.SECONDARY), lp(top = 4)) + // Without a saved order the tables keep their ranking; the list only offers a start point. + rankedDefault = text(getString(R.string.member_order_ranked_default), TextRole.SECONDARY) { visibility = View.GONE } + addView(rankedDefault, lp(top = 8)) fieldButton = choiceChip { chooseField() } directionButton = choiceChip { toggleDirection() } addView(panel(padding = 12) { @@ -146,14 +153,18 @@ class MemberOrderActivity : BaseActivity() { task = { val active = repository.listMemberStatuses(activeOnly = true) val defaults = MemberOrderSorter.sort(active, emptyMap(), MemberSortField.JOIN_DATE, MemberSortDirection.ASCENDING) - val ordered = if (preferences.read().isEmpty()) defaults else preferences.apply(defaults, MemberStatus::uid) - ordered to repository.listSnapshots(1).firstOrNull()?.members.orEmpty().associateBy(SnapshotMember::uid) + val saved = preferences.read() + val ordered = if (saved.isEmpty()) defaults else MemberOrderMerge.apply(saved, defaults, MemberStatus::uid) + val latest = repository.listSnapshots(1).firstOrNull()?.members.orEmpty().associateBy(SnapshotMember::uid) + Triple(ordered, latest, saved.isEmpty()) }, ) { result -> result.fold( - onSuccess = { (ordered, latest) -> + onSuccess = { (ordered, latest, unsaved) -> members = ordered.toMutableList() + shownOrder = ordered.map(MemberStatus::uid) latestMembers = latest + rankedDefault.visibility = if (unsaved) View.VISIBLE else View.GONE stateAdapter.view = if (members.isEmpty()) { StateViews.empty(this, R.drawable.ic_group, getString(R.string.no_active_members)) } else { @@ -211,8 +222,8 @@ class MemberOrderActivity : BaseActivity() { private fun confirmReset() { Dialogs.confirm(this, R.string.reset_member_order, R.string.reset_member_order_warning, R.string.reset) { - work.run(task = { preferences.clear() }) { - Feedback.show(this, R.string.member_order_reset) + work.run(task = { preferences.clear() }) { result -> + Feedback.show(this, if (result.isSuccess) R.string.member_order_reset else R.string.save_failed) load() } } @@ -225,10 +236,20 @@ class MemberOrderActivity : BaseActivity() { adapter.notifyItemRangeChanged(minOf(from, to), kotlin.math.abs(from - to) + 1, POSITION_PAYLOAD) } + /** + * Saves the active members' arrangement; withdrawn members keep their saved places. A failed + * save reloads the list so it shows the order the tables still use. + */ private fun persist() { val order = members.map(MemberStatus::uid) - work.run(task = { check(preferences.write(order)) }) { result -> - if (result.isFailure) Feedback.show(this, R.string.save_failed) + shownOrder = order + work.run(task = { check(preferences.writeActive(order)) }) { result -> + if (result.isSuccess) { + rankedDefault.visibility = View.GONE + } else { + Feedback.show(this, R.string.save_failed) + load() + } } } @@ -338,7 +359,8 @@ class MemberOrderActivity : BaseActivity() { override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { super.clearView(recyclerView, viewHolder) viewHolder.itemView.background?.setTint(getColor(R.color.surface)) - persist() + // A touch or long-press that moves nobody must not turn the ranking into a saved order. + if (MemberOrderMerge.changed(shownOrder, members.map(MemberStatus::uid))) persist() } } 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 a0091f1..a93b31f 100644 --- a/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.settings import android.content.Context +import android.content.SharedPreferences import dev.gf2log.app.management.PlatoonProfileIdentity class MemberOrderPreferences( @@ -8,39 +9,39 @@ class MemberOrderPreferences( private val storageId: String, ) { private val appContext = context.applicationContext - private val scoped = appContext.getSharedPreferences(PROFILE_PREFERENCES, Context.MODE_PRIVATE) + private val scoped = ScopedMemberOrder( + appContext.getSharedPreferences(PROFILE_PREFERENCES, Context.MODE_PRIVATE), + storageId, + ) fun read(): List = if (isLegacy) { UserSettingsPreferences.memberOrder(appContext) } else { - scoped.getString(storageId, null).orEmpty() - .split(',') - .mapNotNull(String::toLongOrNull) - .filter { it > 0L } - .distinct() + scoped.read() } fun write(uids: List): Boolean = if (isLegacy) { UserSettingsPreferences.setMemberOrder(appContext, uids) } else { - scoped.edit().putString(storageId, uids.filter { it > 0L }.distinct().joinToString(",")) - .commit() + scoped.write(uids) } + /** + * Saves a new arrangement of the [active] members. Saved UIDs of members who are not active + * stay next to their previous neighbour, so past weeks and a rejoining member keep their place. + */ + fun writeActive(active: List): Boolean = write(MemberOrderMerge.keepingInactive(read(), active)) + fun clear() { if (isLegacy) { UserSettingsPreferences.clearMemberOrder(appContext) } else { - check(scoped.edit().remove(storageId).commit()) { "Unable to clear the member order" } + check(scoped.clear()) { "Unable to clear the member order" } } } /** Saved UIDs first in saved order; everyone else keeps their incoming order (stable sort). */ - fun apply(items: List, uid: (T) -> Long): List { - val positions = read().withIndex().associate { it.value to it.index } - if (positions.isEmpty()) return items - return items.sortedBy { positions[uid(it)] ?: Int.MAX_VALUE } - } + fun apply(items: List, uid: (T) -> Long): List = MemberOrderMerge.apply(read(), items, uid) private val isLegacy: Boolean get() = storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID @@ -49,3 +50,86 @@ class MemberOrderPreferences( const val PROFILE_PREFERENCES = "platoon_member_order" } } + +/** One Platoon's order inside the shared per-Platoon file, stored as comma-separated UIDs. */ +internal class ScopedMemberOrder( + private val preferences: SharedPreferences, + private val key: String, +) { + fun read(): List = preferences.getString(key, null).orEmpty() + .split(',') + .mapNotNull(String::toLongOrNull) + .filter { it > 0L } + .distinct() + + fun write(uids: List): Boolean = + preferences.commitOrRevert(key) { putString(key, uids.filter { it > 0L }.distinct().joinToString(",")) } + + fun clear(): Boolean = preferences.commitOrRevert(key) { remove(key) } +} + +/** Pure ordering rules shared by the weekly tables and the Member order screen. */ +internal object MemberOrderMerge { + /** Saved UIDs first in saved order; everyone else keeps their incoming order (stable sort). */ + fun apply(saved: List, items: List, uid: (T) -> Long): List { + val positions = saved.withIndex().associate { it.value to it.index } + if (positions.isEmpty()) return items + return items.sortedBy { positions[uid(it)] ?: Int.MAX_VALUE } + } + + /** + * Whether a finished drag rearranged anyone. Releasing a handle without moving it keeps + * [shown], so it must not save: with no saved order that would replace the tables' ranking. + */ + fun changed(shown: List, current: List): Boolean = shown != current + + /** + * [active] in its new order, with every [stored] UID that is not active placed right after its + * nearest earlier stored neighbour (or first when it has none). + */ + fun keepingInactive(stored: List, active: List): List { + val result = active.distinct().toMutableList() + stored.forEachIndexed { index, uid -> + if (uid in result) return@forEachIndexed + // Every earlier stored UID is already placed, active or not. + val after = if (index == 0) -1 else result.indexOf(stored[index - 1]) + result.add(after + 1, uid) + } + return result + } +} + +/** + * Commits [change] to [keys]. A failed commit has already replaced the in-memory values that every + * later read in this process sees, so the previous values are put back before failure is reported. + */ +internal fun SharedPreferences.commitOrRevert( + vararg keys: String, + change: SharedPreferences.Editor.() -> Unit, +): Boolean = commitOrRevert(keys.toList(), change) + +internal fun SharedPreferences.commitOrRevert( + keys: Collection, + change: SharedPreferences.Editor.() -> Unit, +): Boolean { + val stored = all + val previous = keys.associateWith { stored[it] } + if (edit().run { change(); commit() }) return true + edit().run { + previous.forEach { (key, value) -> + @Suppress("UNCHECKED_CAST") + when (value) { + null -> remove(key) + is String -> putString(key, value) + is Long -> putLong(key, value) + is Int -> putInt(key, value) + is Boolean -> putBoolean(key, value) + is Float -> putFloat(key, value) + is Set<*> -> putStringSet(key, value as Set) + } + } + // The in-memory revert is what matters; the disk already holds the previous values. + commit() + } + return false +} 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 223f9bf..3a6ca7c 100644 --- a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.settings import android.content.Context +import android.content.SharedPreferences import dev.gf2log.app.management.PlatoonProfileIdentity data class WeeklyCutlines( @@ -43,12 +44,15 @@ class WeeklyCutlinePreferences( private val storageId: String, ) { private val appContext = context.applicationContext - private val scoped = appContext.getSharedPreferences(PROFILE_PREFERENCES, Context.MODE_PRIVATE) + private val scoped = ScopedWeeklyCutlines( + appContext.getSharedPreferences(PROFILE_PREFERENCES, Context.MODE_PRIVATE), + storageId, + ) fun read(): WeeklyCutlines = if (isLegacy) { UserSettingsPreferences.weeklyCutlines(appContext) } else { - scoped.readWeeklyCutlines(::key) + scoped.read() } fun write(cutlines: WeeklyCutlines) { @@ -56,7 +60,7 @@ class WeeklyCutlinePreferences( UserSettingsPreferences.setWeeklyCutlines(appContext, cutlines) return } - check(scoped.edit().putWeeklyCutlines(cutlines, ::key).commit()) { + check(scoped.write(cutlines)) { "Unable to persist weekly cutlines" } } @@ -66,7 +70,7 @@ class WeeklyCutlinePreferences( write(WeeklyCutlines()) return } - check(scoped.edit().putWeeklyCutlines(WeeklyCutlines(), ::key).commit()) { + check(scoped.write(WeeklyCutlines())) { "Unable to clear weekly cutlines" } } @@ -74,9 +78,23 @@ class WeeklyCutlinePreferences( private val isLegacy: Boolean get() = storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID - private fun key(field: WeeklyCutlineField) = "$storageId.${field.scopedKey}" - private companion object { const val PROFILE_PREFERENCES = "platoon_weekly_cutlines" } } + +/** + * One Platoon's cutlines inside the shared per-Platoon file. A failed commit puts the previous + * values back, so the tables never highlight against a cutline the screen reported as unsaved. + */ +internal class ScopedWeeklyCutlines( + private val preferences: SharedPreferences, + private val storageId: String, +) { + fun read(): WeeklyCutlines = preferences.readWeeklyCutlines(::key) + + fun write(cutlines: WeeklyCutlines): Boolean = + preferences.commitOrRevert(WeeklyCutlineField.entries.map(::key)) { putWeeklyCutlines(cutlines, ::key) } + + private fun key(field: WeeklyCutlineField) = "$storageId.${field.scopedKey}" +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 757321c..f6babcd 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -251,7 +251,8 @@ 최소값 설정 저장 멤버 순서 - 주간 표는 이 순서로 멤버를 표시합니다. 손잡이를 끌어 조정하면 자동으로 저장됩니다. + 순서를 정하면 주간 표가 이 순서로 멤버를 표시합니다. 손잡이를 끌어 조정하면 자동으로 저장됩니다. + 아직 직접 정한 순서가 없습니다. 주간 표는 공적 순(흙먼지 주간에는 점수 순)으로 표시하며, 아래 목록은 조정하기 쉽도록 가입일 순으로 시작합니다. 정렬 기준 정렬 방향 가입일 @@ -429,7 +430,7 @@ 서클 데이터를 불러오지 못했습니다. 현재 초기화 - 기본 가입일 순서로 되돌릴까요? 직접 정한 순서는 삭제됩니다. + 직접 정한 순서를 삭제할까요? 주간 표는 다시 공적 순(흙먼지 주간에는 점수 순)으로 멤버를 표시합니다. 모두 선택 멤버 선택 선택됨 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e5b493..33154b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -251,7 +251,8 @@ Minimum Save settings Member order - Weekly tables list members in this order. Drag a handle to fine-tune it; changes save automatically. + Weekly tables list members in this order once you arrange it. Drag a handle to fine-tune it; changes save automatically. + No custom order yet. Weekly tables rank members by merit, or by score in Gunsmoke weeks; the list below starts in join-date order for arranging. Sort by Direction Join date @@ -433,7 +434,7 @@ Unable to load Platoon data. Present Reset - Return to the default join-date order? Your custom order will be removed. + Remove your custom order? Weekly tables go back to ranking members by merit, or by score in Gunsmoke weeks. Select all Select members Selected diff --git a/app/src/test/java/dev/gf2log/app/settings/FakeSharedPreferences.kt b/app/src/test/java/dev/gf2log/app/settings/FakeSharedPreferences.kt new file mode 100644 index 0000000..88c9562 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/settings/FakeSharedPreferences.kt @@ -0,0 +1,58 @@ +package dev.gf2log.app.settings + +import android.content.SharedPreferences + +/** + * In-memory SharedPreferences that behaves like the platform on a failed disk write: commit() + * updates the in-memory map first and only then reports the failure. [failingCommits] sets how + * many of the next commits fail. + */ +internal class FakeSharedPreferences : SharedPreferences { + val values = linkedMapOf() + var failingCommits = 0 + var commits = 0 + private set + + override fun getAll(): MutableMap = LinkedHashMap(values) + override fun getString(key: String, defValue: String?): String? = values[key] as String? ?: defValue + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String, defValues: MutableSet?): MutableSet? = + values[key] as MutableSet? ?: defValues + override fun getInt(key: String, defValue: Int): Int = values[key] as Int? ?: defValue + override fun getLong(key: String, defValue: Long): Long = values[key] as Long? ?: defValue + override fun getFloat(key: String, defValue: Float): Float = values[key] as Float? ?: defValue + override fun getBoolean(key: String, defValue: Boolean): Boolean = values[key] as Boolean? ?: defValue + override fun contains(key: String): Boolean = key in values + override fun edit(): SharedPreferences.Editor = Editor() + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + + private inner class Editor : SharedPreferences.Editor { + private val changes = linkedMapOf() + private var clear = false + + override fun putString(key: String, value: String?) = apply { changes[key] = value } + override fun putStringSet(key: String, values: MutableSet?) = apply { changes[key] = values?.toMutableSet() } + override fun putInt(key: String, value: Int) = apply { changes[key] = value } + override fun putLong(key: String, value: Long) = apply { changes[key] = value } + override fun putFloat(key: String, value: Float) = apply { changes[key] = value } + override fun putBoolean(key: String, value: Boolean) = apply { changes[key] = value } + override fun remove(key: String) = apply { changes[key] = null } + override fun clear() = apply { clear = true } + + override fun commit(): Boolean { + commits++ + if (clear) values.clear() + changes.forEach { (key, value) -> if (value == null) values.remove(key) else values[key] = value } + if (failingCommits > 0) { + failingCommits-- + return false + } + return true + } + + override fun apply() { + commit() + } + } +} diff --git a/app/src/test/java/dev/gf2log/app/settings/MemberOrderPreferencesTest.kt b/app/src/test/java/dev/gf2log/app/settings/MemberOrderPreferencesTest.kt new file mode 100644 index 0000000..7528ce6 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/settings/MemberOrderPreferencesTest.kt @@ -0,0 +1,103 @@ +package dev.gf2log.app.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MemberOrderPreferencesTest { + private val a = 1L + private val b = 2L + private val c = 3L + private val d = 4L + + @Test + fun `saving the active members keeps a withdrawn member after its neighbour`() { + // C has withdrawn; the leader moves D above A. + val merged = MemberOrderMerge.keepingInactive(stored = listOf(a, b, c, d), active = listOf(d, a, b)) + + assertEquals(listOf(d, a, b, c), merged) + // A past week whose roster still lists C shows C after B, not last. + assertEquals(listOf(d, a, b, c), MemberOrderMerge.apply(merged, listOf(a, b, c, d)) { it }) + } + + @Test + fun `consecutive and leading withdrawn members keep their places`() { + val merged = MemberOrderMerge.keepingInactive(stored = listOf(c, a, d, b), active = listOf(b, a)) + + assertEquals(listOf(c, b, a, d), merged) + } + + @Test + fun `new active members and an empty stored order save as arranged`() { + assertEquals(listOf(b, a), MemberOrderMerge.keepingInactive(stored = emptyList(), active = listOf(b, a))) + assertEquals(listOf(c, a, b), MemberOrderMerge.keepingInactive(stored = listOf(a, b), active = listOf(c, a, b))) + } + + @Test + fun `a drag that ends where it started is no change`() { + assertFalse(MemberOrderMerge.changed(listOf(a, b, c), listOf(a, b, c))) + assertTrue(MemberOrderMerge.changed(listOf(a, b, c), listOf(b, a, c))) + } + + @Test + fun `without a saved order the weekly ranking is kept`() { + val ranked = listOf(c, a, b) + val prefs = FakeSharedPreferences() + val order = ScopedMemberOrder(prefs, "profile") + order.write(listOf(a, b, c)) + + assertTrue(order.clear()) + + assertEquals(emptyList(), order.read()) + assertEquals(ranked, MemberOrderMerge.apply(order.read(), ranked) { it }) + } + + @Test + fun `a failed write leaves the previous order readable`() { + val prefs = FakeSharedPreferences() + val order = ScopedMemberOrder(prefs, "profile") + assertTrue(order.write(listOf(a, b))) + prefs.failingCommits = 1 + + assertFalse(order.write(listOf(b, a))) + + assertEquals(listOf(a, b), order.read()) + } + + @Test + fun `a failed first write leaves no order behind`() { + val prefs = FakeSharedPreferences() + val order = ScopedMemberOrder(prefs, "profile") + prefs.failingCommits = 1 + + assertFalse(order.write(listOf(b, a))) + + assertEquals(emptyList(), order.read()) + assertFalse(prefs.contains("profile")) + } + + @Test + fun `a failed reset keeps the custom order and reports failure`() { + val prefs = FakeSharedPreferences() + val order = ScopedMemberOrder(prefs, "profile") + order.write(listOf(b, a)) + prefs.failingCommits = 1 + + assertFalse(order.clear()) + + assertEquals(listOf(b, a), order.read()) + } + + @Test + fun `other profiles in the shared file are untouched by a revert`() { + val prefs = FakeSharedPreferences() + ScopedMemberOrder(prefs, "other").write(listOf(d)) + val order = ScopedMemberOrder(prefs, "profile") + prefs.failingCommits = 1 + + order.write(listOf(a)) + + assertEquals(listOf(d), ScopedMemberOrder(prefs, "other").read()) + } +} diff --git a/app/src/test/java/dev/gf2log/app/settings/WeeklyCutlinePreferencesTest.kt b/app/src/test/java/dev/gf2log/app/settings/WeeklyCutlinePreferencesTest.kt new file mode 100644 index 0000000..8622368 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/settings/WeeklyCutlinePreferencesTest.kt @@ -0,0 +1,46 @@ +package dev.gf2log.app.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WeeklyCutlinePreferencesTest { + @Test + fun `a failed write keeps the previous cutlines for later reads`() { + val prefs = FakeSharedPreferences() + val cutlines = ScopedWeeklyCutlines(prefs, "profile") + val previous = WeeklyCutlines(dailyMerit = 90, weeklyLoginDays = 5) + assertTrue(cutlines.write(previous)) + prefs.failingCommits = 1 + + assertFalse(cutlines.write(WeeklyCutlines(dailyMerit = 150, dailyGunsmokeAttempts = 3))) + + assertEquals(previous, cutlines.read()) + } + + @Test + fun `a failed clear keeps the cutlines`() { + val prefs = FakeSharedPreferences() + val cutlines = ScopedWeeklyCutlines(prefs, "profile") + val previous = WeeklyCutlines(weeklyMerit = 600, weeklyPatrolDays = 7) + cutlines.write(previous) + prefs.failingCommits = 1 + + assertFalse(cutlines.write(WeeklyCutlines())) + + assertEquals(previous, cutlines.read()) + } + + @Test + fun `a successful write replaces every field`() { + val prefs = FakeSharedPreferences() + val cutlines = ScopedWeeklyCutlines(prefs, "profile") + cutlines.write(WeeklyCutlines(dailyMerit = 90)) + + assertTrue(cutlines.write(WeeklyCutlines(weeklyGunsmokeAttempts = 21))) + + assertEquals(WeeklyCutlines(weeklyGunsmokeAttempts = 21), cutlines.read()) + assertEquals(2, prefs.commits) + } +} From 75e90f8b0f79923feaf31751fe1b33f406fe96d6 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:02:02 +0900 Subject: [PATCH 13/98] Fit long cells in the weekly PNG and keep its summary on one line Each exported row grows to its longest cell, up to six lines, and a cell that still does not fit ends with an ellipsis. The Korean evidence summary drops the line break that Canvas.drawText cannot draw. Fixes ui-a11y-fontscale-10, ui-status-render-5, ui-status-render-4. --- .../dev/gf2log/app/WeeklyReportPngRenderer.kt | 116 +++++++++++++----- app/src/main/res/values-ko/strings.xml | 2 +- .../dev/gf2log/app/WeeklyPngTextLayoutTest.kt | 84 +++++++++++++ .../gf2log/app/WeeklyStringResourcesTest.kt | 50 ++++++++ 4 files changed, 222 insertions(+), 30 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/WeeklyPngTextLayoutTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/WeeklyStringResourcesTest.kt diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt index bf00807..4f0361a 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt @@ -24,12 +24,23 @@ object WeeklyReportPngRenderer { val notesWidth = if (document.includeNotes) 300 else 0 val days = document.headers.size - 2 - if (document.includeNotes) 1 else 0 val naturalWidth = PADDING * 2 + memberWidth + dayWidth * days + totalWidth + notesWidth - val rowHeight = if (document.rows.any { row -> row.dailyCells.any { it.count { c -> c == '\n' } >= 4 } }) { + val paints = CellPaints() + val baseRowHeight = if (document.rows.any { row -> row.dailyCells.any { it.count { c -> c == '\n' } >= 4 } }) { 112 } else { 88 } - val naturalHeight = PADDING * 2 + TITLE_HEIGHT + HEADER_HEIGHT + rowHeight * document.rows.size + // A row grows to show its longest cell (a long note or member label), up to MAX_CELL_LINES. + val rowHeights = document.rows.map { row -> + val lines = buildList { + add(row.member to memberWidth) + row.dailyCells.forEach { add(it to dayWidth) } + add(row.total to totalWidth) + if (document.includeNotes) add(row.privateNote.orEmpty() to notesWidth) + }.maxOf { (value, cellWidth) -> wrap(value, paints.bodyText, cellWidth - CELL_PADDING * 2).size } + maxOf(baseRowHeight, paints.bodyText.heightFor(lines.coerceAtMost(MAX_CELL_LINES))) + } + val naturalHeight = PADDING * 2 + TITLE_HEIGHT + HEADER_HEIGHT + rowHeights.sum() val naturalPixels = naturalWidth.toLong() * naturalHeight val scale = min( 1f, @@ -42,7 +53,6 @@ object WeeklyReportPngRenderer { val height = maxOf(1, (naturalHeight * scale).toInt()) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) - val paints = CellPaints() canvas.drawColor(PAPER) canvas.scale(scale, scale) @@ -88,7 +98,8 @@ object WeeklyReportPngRenderer { } top += HEADER_HEIGHT - document.rows.forEach { row -> + document.rows.forEachIndexed { rowIndex, row -> + val rowHeight = rowHeights[rowIndex] left = PADDING drawCell(canvas, row.member, left, top, memberWidth, rowHeight, paints) left += memberWidth @@ -122,18 +133,23 @@ object WeeklyReportPngRenderer { val bottom = (top + height).toFloat() canvas.drawRect(left.toFloat(), top.toFloat(), right, bottom, paints.fill) canvas.drawRect(left.toFloat(), top.toFloat(), right, bottom, paints.border) - val lines = wrap(value, textPaint, width - CELL_PADDING * 2) - .take(MAX_CELL_LINES) - val lineHeight = textPaint.fontSpacing + val maxWidth = width - CELL_PADDING * 2 + val fit = WeeklyPngTextLayout.linesFitting(height, CELL_PADDING, -textPaint.fontMetrics.top, textPaint.fontSpacing) + .coerceAtMost(MAX_CELL_LINES) + // Text that does not fit ends its last visible line with an ellipsis instead of vanishing. + val lines = WeeklyPngTextLayout.truncate(wrap(value, textPaint, maxWidth), fit) { + textPaint.measureText(it) <= maxWidth + } var baseline = top + CELL_PADDING - textPaint.fontMetrics.top lines.forEach { line -> - if (baseline <= top + height - CELL_PADDING) { - canvas.drawText(line, (left + CELL_PADDING).toFloat(), baseline, textPaint) - } - baseline += lineHeight + canvas.drawText(line, (left + CELL_PADDING).toFloat(), baseline, textPaint) + baseline += textPaint.fontSpacing } } + private fun Paint.heightFor(lines: Int): Int = + WeeklyPngTextLayout.heightFor(lines, CELL_PADDING, -fontMetrics.top, fontSpacing) + /** Paints reused for every cell of one render; each render owns its own set. */ private class CellPaints { val fill = Paint().apply { style = Paint.Style.FILL } @@ -152,7 +168,38 @@ object WeeklyReportPngRenderer { } } - private fun wrap(value: String, paint: Paint, maxWidth: Int): List = buildList { + private fun wrap(value: String, paint: Paint, maxWidth: Int): List = + WeeklyPngTextLayout.wrap(value) { paint.breakText(it, true, maxWidth.toFloat(), null) } + + private val PAPER = Color.rgb(0xF7, 0xF4, 0xEF) + private val SURFACE = Color.rgb(0xFF, 0xFC, 0xF8) + private val HEADER = Color.rgb(0xEC, 0xE7, 0xDF) + private val OUTLINE = Color.rgb(0xC9, 0xC2, 0xB8) + private val INK = Color.rgb(0x25, 0x23, 0x1F) + private val MUTED_INK = Color.rgb(0x6C, 0x66, 0x5F) + private val ACCENT_INK = Color.rgb(0x7D, 0x3F, 0x1E) + private const val PADDING = 28 + private const val TITLE_HEIGHT = 104 + private const val HEADER_HEIGHT = 52 + private const val CELL_PADDING = 9 + private const val MAX_CELL_LINES = 6 + private const val MAX_ROWS = 256 + private const val MAX_DIMENSION = 16_000 + private const val MAX_PIXELS = 12_000_000 +} + +/** + * Cell text layout of the weekly PNG without a Canvas: measuring arrives as functions, so wrapping, + * row heights and truncation share one baseline rule with the draw loop. + */ +internal object WeeklyPngTextLayout { + const val ELLIPSIS = "…" + + /** + * Splits [value] at its own line breaks, then at the last space that keeps each line within the + * cell; [fitCount] returns how many leading characters of a string fit the cell width. + */ + fun wrap(value: String, fitCount: (String) -> Int): List = buildList { value.lineSequence().forEach { source -> var remaining = source.trim() if (remaining.isEmpty()) { @@ -160,8 +207,7 @@ object WeeklyReportPngRenderer { return@forEach } while (remaining.isNotEmpty()) { - val count = paint.breakText(remaining, true, maxWidth.toFloat(), null) - .coerceAtLeast(1) + val count = fitCount(remaining).coerceAtLeast(1) var split = count if (count < remaining.length) { remaining.lastIndexOf(' ', count - 1).takeIf { it > 0 }?.let { split = it } @@ -172,19 +218,31 @@ object WeeklyReportPngRenderer { } } - private val PAPER = Color.rgb(0xF7, 0xF4, 0xEF) - private val SURFACE = Color.rgb(0xFF, 0xFC, 0xF8) - private val HEADER = Color.rgb(0xEC, 0xE7, 0xDF) - private val OUTLINE = Color.rgb(0xC9, 0xC2, 0xB8) - private val INK = Color.rgb(0x25, 0x23, 0x1F) - private val MUTED_INK = Color.rgb(0x6C, 0x66, 0x5F) - private val ACCENT_INK = Color.rgb(0x7D, 0x3F, 0x1E) - private const val PADDING = 28 - private const val TITLE_HEIGHT = 104 - private const val HEADER_HEIGHT = 52 - private const val CELL_PADDING = 9 - private const val MAX_CELL_LINES = 6 - private const val MAX_ROWS = 256 - private const val MAX_DIMENSION = 16_000 - private const val MAX_PIXELS = 12_000_000 + /** + * How many lines a cell of [height] draws: line `i` sits on baseline + * `padding + ascent + i * spacing`, which must stay within `height - padding`. + */ + fun linesFitting(height: Int, padding: Int, ascent: Float, spacing: Float): Int { + val room = height - padding * 2 - ascent + // The epsilon keeps a height from heightFor() at its line count despite float rounding. + return if (room < 0f) 0 else (room / spacing + 1e-3f).toInt() + 1 + } + + /** The smallest cell height whose [linesFitting] covers [lines] lines. */ + fun heightFor(lines: Int, padding: Int, ascent: Float, spacing: Float): Int = + kotlin.math.ceil(padding * 2 + ascent + (lines.coerceAtLeast(1) - 1) * spacing).toInt() + + /** + * The first [fit] of [lines]. When some are left out, the last kept line is shortened until it + * and a trailing [ELLIPSIS] pass [fits], so a cut cell always says that text is missing. + */ + fun truncate(lines: List, fit: Int, fits: (String) -> Boolean): List { + if (lines.size <= fit) return lines + if (fit <= 0) return emptyList() + var last = lines[fit - 1].trimEnd() + while (last.isNotEmpty() && !fits(last + ELLIPSIS)) { + last = last.dropLast(if (last.length >= 2 && Character.isLowSurrogate(last.last())) 2 else 1).trimEnd() + } + return lines.take(fit - 1) + (last + ELLIPSIS) + } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f6babcd..e374eb0 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -488,7 +488,7 @@ 게임 일자 비고를 입력하세요. 과 - 근거: 관측 %1$d/%2$d일\n확정 %3$d · 최솟값 %4$d · 미확인 %5$d + 근거: 관측 %1$d/%2$d일 · 확정 %3$d · 최솟값 %4$d · 미확인 %5$d %1$d위 주 선택 민감한 정보입니다. 기본값은 꺼짐입니다. diff --git a/app/src/test/java/dev/gf2log/app/WeeklyPngTextLayoutTest.kt b/app/src/test/java/dev/gf2log/app/WeeklyPngTextLayoutTest.kt new file mode 100644 index 0000000..f9e87ec --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/WeeklyPngTextLayoutTest.kt @@ -0,0 +1,84 @@ +package dev.gf2log.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WeeklyPngTextLayoutTest { + /** A monospace "font": every character is one unit wide. */ + private val width = 20 + private fun fitCount(text: String) = minOf(text.length, width) + private fun fits(text: String) = text.length <= width + + // Roughly the 16 px body paint: ascent 17, line spacing 19, cell padding 9. + private val padding = 9 + private val ascent = 17f + private val spacing = 19f + + @Test + fun `a twenty-line note is cut to six lines ending in an ellipsis`() { + val note = (1..20).joinToString(" ") { "word%02d".format(it) + " filler" } + val lines = WeeklyPngTextLayout.wrap(note, ::fitCount) + assertTrue(lines.size > 6) + + val drawn = WeeklyPngTextLayout.truncate(lines, 6, ::fits) + + assertEquals(6, drawn.size) + assertEquals(lines.take(5), drawn.take(5)) + assertTrue(drawn.last().endsWith(WeeklyPngTextLayout.ELLIPSIS)) + assertTrue(fits(drawn.last())) + } + + @Test + fun `text that fits is drawn whole without an ellipsis`() { + val lines = WeeklyPngTextLayout.wrap("short note", ::fitCount) + + assertEquals(listOf("short note"), WeeklyPngTextLayout.truncate(lines, 3, ::fits)) + } + + @Test + fun `a row tall enough for its longest cell draws every line`() { + (1..6).forEach { count -> + val height = WeeklyPngTextLayout.heightFor(count, padding, ascent, spacing) + assertEquals(count, WeeklyPngTextLayout.linesFitting(height, padding, ascent, spacing)) + // heightFor is the smallest such height. + assertEquals(count - 1, WeeklyPngTextLayout.linesFitting(height - 1, padding, ascent, spacing)) + } + } + + @Test + fun `the old fixed row heights fit three and five lines`() { + // 88 px rows silently dropped everything after line 3, and 112 px rows after line 5. + assertEquals(3, WeeklyPngTextLayout.linesFitting(88, padding, ascent, spacing)) + assertEquals(5, WeeklyPngTextLayout.linesFitting(112, padding, ascent, spacing)) + assertTrue(WeeklyPngTextLayout.heightFor(6, padding, ascent, spacing) > 112) + } + + @Test + fun `a 400 character note grows its row to the six line cap`() { + val note = "lorem ipsum ".repeat(34).trim() + val lines = WeeklyPngTextLayout.wrap(note, ::fitCount) + val height = WeeklyPngTextLayout.heightFor(minOf(lines.size, 6), padding, ascent, spacing) + + assertEquals(6, WeeklyPngTextLayout.linesFitting(height, padding, ascent, spacing)) + } + + @Test + fun `an ellipsis never splits a surrogate pair`() { + val emoji = "😀" + val lines = listOf("a".repeat(19) + emoji, "more") + + val drawn = WeeklyPngTextLayout.truncate(lines, 1) { it.length <= 20 } + + assertEquals("a".repeat(19) + WeeklyPngTextLayout.ELLIPSIS, drawn.single()) + } + + @Test + fun `wrapping prefers the last space and keeps explicit lines`() { + assertEquals( + listOf("M 90", "S ≥31635 A ≥2"), + WeeklyPngTextLayout.wrap("M 90\nS ≥31635 A ≥2", ::fitCount), + ) + assertEquals(listOf("aaaa bbbb", "cccc"), WeeklyPngTextLayout.wrap("aaaa bbbb cccc") { minOf(it.length, 12) }) + } +} diff --git a/app/src/test/java/dev/gf2log/app/WeeklyStringResourcesTest.kt b/app/src/test/java/dev/gf2log/app/WeeklyStringResourcesTest.kt new file mode 100644 index 0000000..96a27f2 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/WeeklyStringResourcesTest.kt @@ -0,0 +1,50 @@ +package dev.gf2log.app + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element + +class WeeklyStringResourcesTest { + private val res = listOf(File("src/main/res"), File("app/src/main/res")).first { it.isDirectory } + + /** Canvas.drawText never breaks lines, so the PNG summary must be one line in every locale. */ + @Test + fun `the PNG evidence summary is a single line in every locale`() { + val values = everyLocale("png_evidence_summary") + assertTrue(values.keys.containsAll(listOf("values", "values-ko"))) + + values.forEach { (locale, value) -> + assertFalse("$locale: $value", value.contains("\\n") || value.contains('\n')) + // All five counts are still drawn. + assertEquals(locale, 5, Regex("%[1-5]\\\$d").findAll(value).count()) + } + } + + /** With no saved order the tables rank members, so resetting does not promise join-date order. */ + @Test + fun `the member order reset warning does not promise join-date order`() { + val english = everyLocale("reset_member_order_warning").getValue("values") + val korean = everyLocale("reset_member_order_warning").getValue("values-ko") + + assertFalse(english, english.contains("join", ignoreCase = true)) + assertFalse(korean, korean.contains("가입일")) + } + + private fun everyLocale(name: String): Map = + res.listFiles { file -> file.isDirectory && file.name.startsWith("values") }.orEmpty() + .mapNotNull { directory -> File(directory, "strings.xml").takeIf(File::isFile) } + .mapNotNull { file -> string(file, name)?.let { file.parentFile.name to it } } + .toMap() + + private fun string(file: File, name: String): String? { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val strings = document.getElementsByTagName("string") + return (0 until strings.length).map { strings.item(it) as Element } + .firstOrNull { it.getAttribute("name") == name } + ?.textContent + } +} From 66b65efc4cc671cccc2fb79576eddfc93fe6f25f Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:02:21 +0900 Subject: [PATCH 14/98] Make state changes, tooltips and large text accessible Information rows such as Version are no longer exposed as buttons, a renamed navigation or clear button updates its tooltip, re-applied top-bar actions keep their buttons so screen-reader focus survives Select all, a changed top-bar title and every loading, empty and failure state are announced, roster details wrap instead of hiding last login, and boundary date and time fields stack at large font sizes instead of clipping digits. Findings: q-uiux-12, ui-a11y-fontscale-2, ui-a11y-fontscale-5, ui-a11y-fontscale-6, ui-a11y-fontscale-7, ui-a11y-fontscale-8 --- .../dev/gf2log/app/DateTimePickerInput.kt | 18 ++++++++-- .../java/dev/gf2log/app/OptionsActivity.kt | 2 +- .../java/dev/gf2log/app/PlatoonMemberRow.kt | 6 ++-- .../main/java/dev/gf2log/app/ui/StateViews.kt | 25 ++++++++++++-- app/src/main/java/dev/gf2log/app/ui/TopBar.kt | 34 +++++++++++++++++-- app/src/main/java/dev/gf2log/app/ui/Views.kt | 17 +++++++--- .../dev/gf2log/app/ui/TopBarControlsTest.kt | 33 ++++++++++++++++++ 7 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/ui/TopBarControlsTest.kt diff --git a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt index b05c339..e6f911c 100644 --- a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt +++ b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt @@ -12,6 +12,7 @@ import dev.gf2log.app.management.MembershipBoundaryValue import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.column import dev.gf2log.app.ui.dp import dev.gf2log.app.ui.dpf import dev.gf2log.app.ui.iconButton @@ -63,8 +64,16 @@ internal class DateTimePickerInput( setPadding(context.dp(4), 0, context.dp(4), context.dp(4)) }, context.lp()) addView(context.row { - addView(dateField, context.lp(width = 0, weight = 1.3f)) - addView(timeField, context.lp(width = 0, weight = 1f, start = 8)) + if (stacksPickerFields(context.resources.configuration.fontScale)) { + // Side by side, large digits would not fit a narrow dialog; each field gets the row. + addView(context.column { + addView(dateField, context.lp()) + addView(timeField, context.lp(top = 8)) + }, context.lp(width = 0, weight = 1f)) + } else { + addView(dateField, context.lp(width = 0, weight = 1.3f)) + addView(timeField, context.lp(width = 0, weight = 1f, start = 8)) + } addView(clearButton, context.lp(48, 48, start = 4)) }, context.lp()) if (!editable) { @@ -80,7 +89,6 @@ internal class DateTimePickerInput( // Grows with the font size instead of clipping; 48dp stays the minimum touch target. minHeight = context.dp(48) setPadding(context.dp(12), context.dp(6), context.dp(8), context.dp(6)) - maxLines = 1 background = GradientDrawable().apply { setColor(context.getColor(R.color.surface_variant)) cornerRadius = context.dpf(12f) @@ -146,5 +154,9 @@ internal class DateTimePickerInput( clearButton.contentDescription = context.getString( if (time != null) R.string.clear_time else R.string.clear_date, ) + clearButton.tooltipText = clearButton.contentDescription } } + +/** Large text stacks the date and time fields so neither picked value is clipped. */ +internal fun stacksPickerFields(fontScale: Float): Boolean = fontScale >= 1.3f diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index eeab6d5..9d115bb 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -189,7 +189,7 @@ class OptionsActivity : BaseActivity() { Intent(this@OptionsActivity, OnboardingActivity::class.java).putExtra(OnboardingActivity.EXTRA_REPLAY, true), ) }, - navigationRow(getString(R.string.app_version), versionName(), R.drawable.ic_code, showChevron = false) {}, + navigationRow(getString(R.string.app_version), versionName(), R.drawable.ic_code, showChevron = false), )), lp()) } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt b/app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt index c0faf7a..372434a 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt @@ -40,10 +40,8 @@ internal class PlatoonMemberRow(private val context: Context) { ellipsize = TextUtils.TruncateAt.END } private val status = context.badge("") - private val detail = context.text(null, TextRole.CAPTION) { - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - } + // Wraps at large font sizes: the last login is the active sort key and must stay readable. + private val detail = context.text(null, TextRole.CAPTION) private val merit = context.text(null, TextRole.SUBTITLE) { gravity = Gravity.END } init { diff --git a/app/src/main/java/dev/gf2log/app/ui/StateViews.kt b/app/src/main/java/dev/gf2log/app/ui/StateViews.kt index afc8d4d..6ba1ce6 100644 --- a/app/src/main/java/dev/gf2log/app/ui/StateViews.kt +++ b/app/src/main/java/dev/gf2log/app/ui/StateViews.kt @@ -4,6 +4,7 @@ import android.content.Context import android.view.Gravity import android.view.View import android.widget.ProgressBar +import android.widget.TextView import androidx.annotation.DrawableRes import dev.gf2log.app.R @@ -17,10 +18,10 @@ internal object StateViews { indeterminateTintList = android.content.res.ColorStateList.valueOf(context.getColor(R.color.accent)) importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, context.lp(36, 36)) - addView(context.text(message, TextRole.SECONDARY) { + addView(context.text(null, TextRole.SECONDARY) { gravity = Gravity.CENTER setPadding(0, context.dp(12), 0, 0) - accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + announceWhenAttached(message) }, context.lp()) } @@ -35,9 +36,10 @@ internal object StateViews { gravity = Gravity.CENTER_HORIZONTAL setPadding(context.dp(24), context.dp(40), context.dp(24), context.dp(40)) addView(context.icon(icon, R.color.outline_strong, sizeDp = 40), context.lp(40, 40)) - addView(context.text(title, TextRole.SUBTITLE) { + addView(context.text(null, TextRole.SUBTITLE) { gravity = Gravity.CENTER setPadding(0, context.dp(12), 0, 0) + announceWhenAttached(title) }, context.lp()) detail?.let { addView(context.text(it, TextRole.SECONDARY) { @@ -61,3 +63,20 @@ internal object StateViews { onAction = onRetry, ) } + +/** + * A live region announces changes, not its own arrival, and a state view replaces the previous + * one as a new view. The message is therefore set just after the view is attached, so screen + * readers hear that a screen finished loading, came back empty, or failed. + */ +private fun TextView.announceWhenAttached(message: CharSequence) { + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(view: View) { + view.removeOnAttachStateChangeListener(this) + view.post { text = message } + } + + override fun onViewDetachedFromWindow(view: View) = Unit + }) +} diff --git a/app/src/main/java/dev/gf2log/app/ui/TopBar.kt b/app/src/main/java/dev/gf2log/app/ui/TopBar.kt index 3efb873..2c95eb3 100644 --- a/app/src/main/java/dev/gf2log/app/ui/TopBar.kt +++ b/app/src/main/java/dev/gf2log/app/ui/TopBar.kt @@ -36,6 +36,8 @@ internal class TopBar(context: Context) : LinearLayout(context) { maxLines = 1 ellipsize = TextUtils.TruncateAt.END accessibilityHeading() + // A title that changes on a showing screen is state, such as "3 selected"; it is spoken. + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE } private val subtitleView = context.text(null, TextRole.CAPTION) { maxLines = 1 @@ -43,6 +45,8 @@ internal class TopBar(context: Context) : LinearLayout(context) { visibility = View.GONE } private val actions = context.row() + private var shownActions = emptyList() + private var shownOverflow = emptyList() init { orientation = HORIZONTAL @@ -63,7 +67,8 @@ internal class TopBar(context: Context) : LinearLayout(context) { } fun setTitle(value: CharSequence?, subtitle: CharSequence? = null): TopBar = apply { - titleView.text = value + // Re-applying the same title must not announce it again. + if (!TextUtils.equals(titleView.text, value)) titleView.text = value subtitleView.text = subtitle subtitleView.visibility = if (subtitle.isNullOrBlank()) View.GONE else View.VISIBLE } @@ -76,6 +81,7 @@ internal class TopBar(context: Context) : LinearLayout(context) { fun setNavigationIcon(@DrawableRes icon: Int, description: CharSequence): TopBar = apply { navigation.setImageResource(icon) navigation.contentDescription = description + navigation.tooltipText = description } fun setLeading(view: View?): TopBar = apply { @@ -84,7 +90,24 @@ internal class TopBar(context: Context) : LinearLayout(context) { view?.let { leading.addView(it) } } + /** + * Shows [items] and the [overflow] menu. Re-applying the same controls, such as after Select + * all, updates the existing buttons in place, so the button a screen reader is focused on + * stays in the bar instead of being replaced. + */ fun setActions(items: List, overflow: List = emptyList()): TopBar = apply { + val reuse = sameControls(shownActions, items) && shownOverflow.isEmpty() == overflow.isEmpty() + shownActions = items + shownOverflow = overflow + if (reuse) { + items.forEachIndexed { index, action -> + actions.getChildAt(index).apply { + isEnabled = action.enabled + setOnClickListener(action.onClick) + } + } + return@apply + } actions.removeAllViews() items.forEach { action -> actions.addView(context.iconButton(action.icon, action.label, onClick = action.onClick).apply { @@ -93,8 +116,9 @@ internal class TopBar(context: Context) : LinearLayout(context) { } if (overflow.isNotEmpty()) { actions.addView( + // Reads the latest entries, so an in-place update also refreshes the menu. context.iconButton(R.drawable.ic_more_vert, context.getString(R.string.more_options)) { anchor -> - showMenu(anchor, overflow) + showMenu(anchor, shownOverflow) }, context.lp(48, 48), ) @@ -113,3 +137,9 @@ internal class TopBar(context: Context) : LinearLayout(context) { }.show() } } + +/** Whether [next] shows the same buttons as [current]: same icons and labels, in order. */ +internal fun sameControls(current: List, next: List): Boolean = + current.size == next.size && current.zip(next).all { (shown, wanted) -> + shown.icon == wanted.icon && shown.label.toString() == wanted.label.toString() + } diff --git a/app/src/main/java/dev/gf2log/app/ui/Views.kt b/app/src/main/java/dev/gf2log/app/ui/Views.kt index 64fad44..5952c3e 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Views.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -391,7 +391,10 @@ internal fun View.setShownInGroup(shown: Boolean) { if (index > 0) parent.getChildAt(index - 1).visibility = visibility } -/** A tappable grouped row with an optional icon, supporting text, and trailing value. */ +/** + * A grouped row with an optional icon, supporting text, and trailing value. Without [onClick] it + * is an information row: focusable so it is read, but neither clickable nor announced as a button. + */ internal fun Context.navigationRow( title: CharSequence, detail: CharSequence? = null, @@ -400,17 +403,21 @@ internal fun Context.navigationRow( destructive: Boolean = false, enabled: Boolean = true, showChevron: Boolean = true, - onClick: () -> Unit, -): View = AccessibleRow(this).apply { + onClick: (() -> Unit)? = null, +): View = (if (onClick == null) LinearLayout(this) else AccessibleRow(this)).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL minimumHeight = dp(if (detail.isNullOrBlank()) 52 else 64) setPadding(dp(8), dp(8), dp(4), dp(8)) - background = selectableRect() isEnabled = enabled contentDescription = listOfNotNull(title, trailing, detail?.takeIf(CharSequence::isNotBlank)) .joinToString(". ") - setOnClickListener { onClick() } + if (onClick == null) { + isFocusable = true + } else { + background = selectableRect() + setOnClickListener { onClick() } + } val primaryColor = when { !enabled -> R.color.text_secondary destructive -> R.color.destructive_action diff --git a/app/src/test/java/dev/gf2log/app/ui/TopBarControlsTest.kt b/app/src/test/java/dev/gf2log/app/ui/TopBarControlsTest.kt new file mode 100644 index 0000000..9638b15 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/ui/TopBarControlsTest.kt @@ -0,0 +1,33 @@ +package dev.gf2log.app.ui + +import dev.gf2log.app.stacksPickerFields +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TopBarControlsTest { + private val selectAll = BarAction(1, "Select all") {} + private val export = BarAction(2, "Export selected members", enabled = false) {} + + @Test + fun `re-applying the same buttons keeps them, whatever their state or handler`() { + assertTrue(sameControls(listOf(selectAll, export), listOf(selectAll.copy(onClick = {}), export.copy(enabled = true)))) + assertTrue(sameControls(emptyList(), emptyList())) + } + + @Test + fun `a different icon, label, order, or count rebuilds the bar`() { + assertFalse(sameControls(listOf(selectAll, export), listOf(export, selectAll))) + assertFalse(sameControls(listOf(selectAll), listOf(selectAll.copy(icon = 3)))) + assertFalse(sameControls(listOf(selectAll), listOf(selectAll.copy(label = "Clear selection")))) + assertFalse(sameControls(listOf(selectAll), listOf(selectAll, export))) + } + + @Test + fun `large text stacks the boundary date and time fields`() { + assertFalse(stacksPickerFields(1f)) + assertFalse(stacksPickerFields(1.15f)) + assertTrue(stacksPickerFields(1.3f)) + assertTrue(stacksPickerFields(2f)) + } +} From fc1b034ae960186ce43aacfb7b67e8aa99ba2a98 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:02:26 +0900 Subject: [PATCH 15/98] Keep a near-reset Standard day exact only when one split remains Every delta measured from a baseline just before 05:00 now goes through the prefix/tail split, narrowed by login times and Daily Patrol events at both captures, and a capture after Sunday with an earlier login makes the Standard Sunday 0 like every other day. Findings: q-weekly-engine-1, q-weekly-engine-m1 --- .../app/management/PlatoonRepository.kt | 3 +- .../app/management/StandardWeekSolver.kt | 76 +++++++++--- .../app/management/WeeklyReportBuilder.kt | 104 +++++++++++++---- .../app/management/WeeklyReportBuilderTest.kt | 109 ++++++++++++++++++ docs/PLATOON_MANAGEMENT.md | 8 +- 5 files changed, 262 insertions(+), 38 deletions(-) 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 2da6214..6ab4929 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -398,8 +398,9 @@ internal class PlatoonRepository( reportEvents = roster.reportEvents, revisionEvents = roster.revisionEvents, overrides = database.listWeeklyOverrides(periodStart.toEpochDay()), + // From the day before the week: its Daily Patrol bounds a near-reset tail. dailyPatrolFacts = database.listDailyPatrolFacts( - PlatoonPeriods.periodStartInstant(periodStart, zoneId), + PlatoonPeriods.periodStartInstant(periodStart.minusDays(1), zoneId), PlatoonPeriods.periodStartInstant(periodStart.plusDays(7), zoneId), ), notes = database.listWeeklyNotes(periodStart.toEpochDay()), 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 97fde3d..839b666 100644 --- a/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt @@ -29,6 +29,7 @@ object StandardWeekSolver { zoneId = zoneId, snapshots = snapshots, cells = cells, + asOf = asOf, ).toMutableList() val observations = snapshots.mapNotNull { snapshot -> snapshot.member(uid)?.let { member -> @@ -67,18 +68,7 @@ object StandardWeekSolver { if (activeIndexes.isEmpty()) return Resolution(mutable, null) val confirmedNoLoginDays = activeIndexes.mapNotNull { index -> - val day = days[index] - val start = PlatoonPeriods.periodStartInstant(day, zoneId) - val end = PlatoonPeriods.periodStartInstant(day.plusDays(1), zoneId) - day.takeIf { - !end.isAfter(asOf) && - snapshots.any { snapshot -> - !snapshot.capturedAt.isBefore(end) && - snapshot.member(uid)?.lastLogin?.takeIf { it > 0L }?.let { - Instant.ofEpochSecond(it).isBefore(start) - } == true - } - } + days[index].takeIf { isConfirmedNoLoginDay(uid, it, zoneId, snapshots, asOf) } }.toSet() val compatible = compatibleAllocations( indexes = activeIndexes, @@ -165,17 +155,63 @@ object StandardWeekSolver { ) } + /** + * Whether a capture at or after [day]'s end still shows a login before its start, so the + * member did not log in, and earned nothing, that day. + */ + private fun isConfirmedNoLoginDay( + uid: Long, + day: LocalDate, + zoneId: ZoneId, + snapshots: List, + asOf: Instant, + ): Boolean { + val start = PlatoonPeriods.periodStartInstant(day, zoneId) + val end = PlatoonPeriods.periodStartInstant(day.plusDays(1), zoneId) + return !end.isAfter(asOf) && + snapshots.any { snapshot -> + !snapshot.capturedAt.isBefore(end) && + snapshot.member(uid)?.lastLogin?.takeIf { it > 0L }?.let { + Instant.ofEpochSecond(it).isBefore(start) + } == true + } + } + + /** + * Completes Sunday, the last day of the previous counter week, outside the Monday-to-Saturday + * allocation. Like every other day, a confirmed no-login Sunday is 0; otherwise its observed + * prefix, consistent with the latest Sunday capture's login time, is extended by the merit the + * first later capture shows after it. + */ private fun inferSundayMerit( uid: Long, days: List, zoneId: ZoneId, snapshots: List, cells: List, + asOf: Instant, ): List { val sundayCell = cells.first() - if (sundayCell.manualOverride != null || sundayCell.hasClosingBoundary) { - return cells + if (sundayCell.manualOverride != null) return cells + if ( + !sundayCell.hasDailyPatrolFact && + !sundayCell.hasLoginFact && + isConfirmedNoLoginDay(uid, days.first(), zoneId, snapshots, asOf) + ) { + return cells.toMutableList().apply { + this[0] = sundayCell.copy( + meritDelta = 0L, + scoreDelta = 0L, + inference = ActivityInference.infer( + meritDelta = 0L, + scoreDelta = 0L, + gunsmokeActive = false, + ), + evidence = DailyEvidence.ATTRIBUTED, + ) + } } + if (sundayCell.hasClosingBoundary) return cells val monday = days.first().plusDays(1) val mondayStart = PlatoonPeriods.periodStartInstant(monday, zoneId) val sundayObservation = snapshots.lastOrNull { snapshot -> @@ -196,6 +232,18 @@ object StandardWeekSolver { if (remainingSundayMerit < 0L) return cells val observedSundayPrefix = sundayCell.meritDelta ?: return cells + val sundayStart = PlatoonPeriods.periodStartInstant(days.first(), zoneId) + val prefixMatchesLogin = sundayMember.lastLogin.takeIf { it > 0L } + ?.let(Instant::ofEpochSecond) + ?.let { login -> + if (login.isBefore(sundayStart)) { + observedSundayPrefix == 0L + } else { + observedSundayPrefix == LOGIN_MERIT || observedSundayPrefix == PATROL_MERIT + } + } + ?: (observedSundayPrefix in DAILY_STAGE) + if (!prefixMatchesLogin) return cells val merit = observedSundayPrefix + remainingSundayMerit if (merit !in DAILY_STAGE) return cells return cells.toMutableList().apply { 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 4583128..86343bb 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt @@ -299,8 +299,9 @@ object WeeklyReportBuilder { snapshots = sortedSnapshots, overridesByCell = overridesByCell, dailyPatrolFacts = dailyPatrolFacts, + // The day before the week is kept too: its Daily Patrol bounds a near-reset tail. patrolFactsByMemberDay = dailyPatrolFacts - .filter { PlatoonPeriods.gameDay(it.occurredAt, zoneId) in days } + .filter { PlatoonPeriods.gameDay(it.occurredAt, zoneId) in start.minusDays(1)..days.last() } .groupBy { it.uid to PlatoonPeriods.gameDay(it.occurredAt, zoneId) } .mapValues { (_, facts) -> facts.minBy(DailyPatrolFact::occurredAt) }, loginFactsByMemberDay = loginFactsByMemberDay(sortedSnapshots, days, zoneId), @@ -430,6 +431,7 @@ object WeeklyReportBuilder { isGunsmoke = week.isGunsmoke, manualOverride = week.overridesByCell[uid to day], dailyPatrolFact = week.patrolFactsByMemberDay[uid to day], + previousDayPatrolFact = week.patrolFactsByMemberDay[uid to day.minusDays(1)], hasLoginFact = uid to day in week.loginFactsByMemberDay, ) } @@ -536,6 +538,7 @@ object WeeklyReportBuilder { isGunsmoke: Boolean, manualOverride: WeeklyCellOverride?, dailyPatrolFact: DailyPatrolFact?, + previousDayPatrolFact: DailyPatrolFact?, hasLoginFact: Boolean, ): DayCell { if (manualOverride != null) { @@ -547,6 +550,7 @@ object WeeklyReportBuilder { isGunsmoke = isGunsmoke, manualOverride = null, dailyPatrolFact = dailyPatrolFact, + previousDayPatrolFact = previousDayPatrolFact, hasLoginFact = hasLoginFact, ) return overlayManualOverride(derived, manualOverride, isGunsmoke) @@ -634,15 +638,40 @@ object WeeklyReportBuilder { } else { val lastKnown = after ?: before val intervalMerit = ActivityInference.counterDelta(before.totalMerit, lastKnown.totalMerit) - meritDelta = if ( - !isGunsmoke && - baselineSnapshot.capturedAt != start && - intervalMerit > STANDARD_PATROL_MERIT - ) { + meritDelta = if (!isGunsmoke && baselineSnapshot.capturedAt != start) { // 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() + // delta, so the day is kept only when exactly one split remains; it is never + // shown above the cap. Login times and Daily Patrol events at either capture + // narrow the stage each day can have reached. + val previousDay = day.minusDays(1) + val previousStart = PlatoonPeriods.periodStartInstant(previousDay, zoneId) + nearResetStandardPrefix( + delta = intervalMerit, + // Only a Gunsmoke Saturday's tail can hold attempts, and they move its score. + previousDayHasAttempts = PlatoonPeriods.isGunsmokeWeek(previousDay) && + lastKnown.totalScore != before.totalScore, + previousReachedStages = standardStagesAt( + capturedAt = baselineSnapshot.capturedAt, + lastLogin = before.lastLogin, + dayStart = previousStart, + dayEnd = start, + patrolAt = previousDayPatrolFact?.occurredAt, + ), + previousFinalStages = if (previousDayPatrolFact != null) { + setOf(STANDARD_PATROL_MERIT) + } else { + STANDARD_STAGES.toSet() + }, + prefixStages = lastObservation?.let { (snapshot, member) -> + standardStagesAt( + capturedAt = snapshot.capturedAt, + lastLogin = member.lastLogin, + dayStart = start, + dayEnd = end, + patrolAt = dailyPatrolFact?.occurredAt, + ) + } ?: STANDARD_STAGES.toSet(), + ) ?: return withActivityFacts( DayCell( gameDay = day, @@ -673,7 +702,8 @@ object WeeklyReportBuilder { // boundary: merit can change before reset. Exactness is // established later by the counter constraint solver. evidence = if (!isGunsmoke && before != null && meritDelta == STANDARD_PATROL_MERIT) { - // The 90-point cap completes a Standard day before its reset. + // The 90-point cap completes a Standard day before its reset. After a + // near-reset baseline it is the only split left, so no tail remains in it. DailyEvidence.ATTRIBUTED } else { DailyEvidence.PARTIAL_DAY @@ -739,11 +769,51 @@ 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. + * The new Standard day's prefix in [delta], a counter change measured from a baseline just + * before its 05:00 reset, or null unless exactly one split remains. The previous day finishes + * at one of [previousFinalStages] at or above a stage of [previousReachedStages] it had + * reached by the baseline; that difference is its tail, so a tail can only add the login, the + * Daily Patrol, or both to a day that had not finished them yet; a Gunsmoke tail with attempts + * ([previousDayHasAttempts]) can also add attempt merit, so any tail fits. The new day has + * reached one of [prefixStages] by its latest capture. */ - private fun standardPrefixesAfterTail(delta: Long): List = - STANDARD_STAGES.filter { prefix -> delta - prefix in STANDARD_TAILS } + internal fun nearResetStandardPrefix( + delta: Long, + previousReachedStages: Set, + previousFinalStages: Set, + prefixStages: Set, + previousDayHasAttempts: Boolean = false, + ): Long? { + val tails = previousReachedStages.flatMapTo(mutableSetOf()) { reached -> + previousFinalStages.filter { it >= reached }.map { it - reached } + } + return STANDARD_STAGES.filter { prefix -> + val tail = delta - prefix + prefix in prefixStages && (tail in tails || (previousDayHasAttempts && tail >= 0L)) + }.singleOrNull() + } + + /** + * The Standard stages a day running from [dayStart] to [dayEnd] can have reached by a capture + * at [capturedAt]. Its first Daily Patrol at [patrolAt] completes it at the cap, a login time + * before the day proves it has not started, and one during the day proves the login. + */ + private fun standardStagesAt( + capturedAt: Instant, + lastLogin: Long, + dayStart: Instant, + dayEnd: Instant, + patrolAt: Instant?, + ): Set { + if (patrolAt != null && !patrolAt.isAfter(capturedAt)) return setOf(STANDARD_PATROL_MERIT) + val withoutPatrol = if (patrolAt != null) STANDARD_STAGES - STANDARD_PATROL_MERIT else STANDARD_STAGES + val login = lastLogin.takeIf { it > 0L }?.let(Instant::ofEpochSecond) + return when { + login == null || !login.isBefore(dayEnd) -> withoutPatrol + login.isBefore(dayStart) -> setOf(0L) + else -> withoutPatrol - 0L + }.toSet() + } private fun List.lastBefore(instant: Instant): PlatoonSnapshot? = lastOrNull { it.capturedAt.isBefore(instant) } @@ -799,10 +869,4 @@ object WeeklyReportBuilder { 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/test/java/dev/gf2log/app/management/WeeklyReportBuilderTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyReportBuilderTest.kt index 5f0462f..caff519 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyReportBuilderTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyReportBuilderTest.kt @@ -1029,6 +1029,106 @@ class WeeklyReportBuilderTest { assertEquals(MetricCertainty.UNKNOWN, ambiguous.meritCertainty) } + @Test + fun nearResetTailAtOrBelowTheCapIsNotCreditedToASundayWithoutLogin() { + // A 04:55 Sunday baseline, then Saturday's last minutes earn [tail]; the Monday 04:55 + // capture still shows a Saturday login, so Sunday itself earned nothing. + fun week(tail: Long, baselineLogin: String, tailLogin: String) = WeeklyReportBuilder.build( + referenceDay = LocalDate.of(2026, 7, 26), + zoneId = zone, + snapshots = listOf( + capture("2026-07-25T19:55:00Z", weekly = 450, total = 10_000, lastLogin = baselineLogin), + capture("2026-07-26T19:55:00Z", weekly = 450 + tail, total = 10_000 + tail, lastLogin = tailLogin), + // A full Monday-to-Saturday counter. + capture("2026-08-01T19:55:00Z", weekly = 540, total = 10_540 + tail, lastLogin = "2026-08-01T01:00:00Z"), + ), + asOf = Instant.parse("2026-08-02T00:00:00Z"), + ).members.single() + + val friday = "2026-07-24T10:00:00Z" + val saturday = "2026-07-25T01:00:00Z" + val tailLogin = "2026-07-25T19:57:00Z" + listOf( + week(tail = 90, baselineLogin = friday, tailLogin = tailLogin), + week(tail = 50, baselineLogin = friday, tailLogin = tailLogin), + // Saturday had logged in already, so only its Daily Patrol is left for the tail. + week(tail = 40, baselineLogin = saturday, tailLogin = saturday), + ).forEach { row -> + val sunday = row.days.first() + assertEquals(0L, sunday.meritDelta) + assertEquals(MetricCertainty.EXACT, sunday.meritCertainty) + assertEquals(false, sunday.attended) + assertEquals(false, sunday.dailyPatrol) + assertEquals(540L, row.totalMerit) + assertEquals(MetricCertainty.EXACT, row.totalMeritCertainty) + } + } + + @Test + fun nearResetBaselineStaysExactWhenThePreviousDayWasAlreadyComplete() { + fun sunday(facts: List) = WeeklyReportBuilder.build( + referenceDay = LocalDate.of(2026, 7, 26), + zoneId = zone, + snapshots = listOf( + capture("2026-07-25T19:55:00Z", weekly = 450, total = 10_000, lastLogin = "2026-07-25T01:00:00Z"), + capture("2026-07-26T19:55:00Z", weekly = 540, total = 10_090, lastLogin = "2026-07-26T00:00:00Z"), + ), + dailyPatrolFacts = facts, + asOf = Instant.parse("2026-07-26T20:30:00Z"), + ).members.single().days.first() + + // Saturday's Daily Patrol before the baseline leaves no tail: the 90 is Sunday's own. + val completed = sunday(listOf(DailyPatrolFact(1, Instant.parse("2026-07-25T03:00:00Z")))) + assertEquals(90L, completed.meritDelta) + assertTrue(completed.evidence != DailyEvidence.INCOMPLETE_BOUNDARY) + assertTrue(completed.meritCertainty != MetricCertainty.UNKNOWN) + + // Otherwise Saturday's Daily Patrol can still be in it: 90 + 0 or 50 + 40, so only + // Sunday's own login remains certain. + val open = sunday(emptyList()) + assertEquals(50L, open.meritDelta) + assertEquals(MetricCertainty.LOWER_BOUND, open.meritCertainty) + } + + @Test + fun aLaterCaptureWithAnEarlierLoginMakesTheStandardSundayZero() { + val report = WeeklyReportBuilder.build( + referenceDay = LocalDate.of(2026, 7, 26), + zoneId = zone, + snapshots = listOf( + capture("2026-07-25T19:55:00Z", weekly = 450, total = 10_000, lastLogin = "2026-07-24T10:00:00Z"), + // Monday noon: still no login since Friday. + capture("2026-07-27T03:00:00Z", weekly = 0, total = 10_000, lastLogin = "2026-07-24T10:00:00Z"), + ), + asOf = Instant.parse("2026-07-27T04:00:00Z"), + ) + + val sunday = report.members.single().days.first() + assertEquals(0L, sunday.meritDelta) + assertEquals(MetricCertainty.EXACT, sunday.meritCertainty) + assertEquals(false, sunday.attended) + } + + @Test + fun nearResetSplitUsesEveryStageBothDaysCanHaveReached() { + val all = setOf(0L, 50L, 90L) + // Without login or patrol times, 90 is either day's alone and 130 can only be 90 + 40. + assertNull(WeeklyReportBuilder.nearResetStandardPrefix(90, all, all, all)) + assertEquals(90L, WeeklyReportBuilder.nearResetStandardPrefix(130, all, all, all)) + // The previous day had logged in: only its 40-point Daily Patrol can remain. + assertEquals(50L, WeeklyReportBuilder.nearResetStandardPrefix(50, setOf(50L, 90L), all, all)) + assertNull(WeeklyReportBuilder.nearResetStandardPrefix(90, setOf(50L, 90L), all, all)) + // The new day has not logged in yet. + assertEquals(0L, WeeklyReportBuilder.nearResetStandardPrefix(90, all, all, setOf(0L))) + assertNull(WeeklyReportBuilder.nearResetStandardPrefix(40, setOf(0L), all, setOf(0L))) + // A Gunsmoke Saturday tail with attempts can hold any merit. + assertNull(WeeklyReportBuilder.nearResetStandardPrefix(130, all, all, all, previousDayHasAttempts = true)) + assertEquals( + 0L, + WeeklyReportBuilder.nearResetStandardPrefix(130, all, all, setOf(0L), previousDayHasAttempts = true), + ) + } + @Test fun midWeekJoinerMeritIsNotCreditedToTheFirstObservedDay() { val present = member(uid = 1, name = "Present", weekly = 90, score = 0) @@ -1360,6 +1460,15 @@ class WeeklyReportBuilderTest { members = listOf(SnapshotMember(1, "One", 60, weekly, weekly, 0, score, 0)), ) + private fun capture(time: String, weekly: Long, total: Long, lastLogin: String) = + PlatoonSnapshot( + id = 0, + capturedAt = Instant.parse(time), + members = listOf( + SnapshotMember(1, "One", 60, weekly, total, 0, 0, Instant.parse(lastLogin).epochSecond), + ), + ) + private fun snapshotWithMembers(time: String, vararg members: SnapshotMember) = PlatoonSnapshot(0, Instant.parse(time), members.toList()) diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 56a5d7f..b5cfe9c 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -111,9 +111,11 @@ transitions. 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. 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. + more than 90: any delta after a near-reset baseline can contain the previous + day's login or patrol tail, so the day keeps a value only when a single split + of that delta remains. Login times and Daily Patrol events at the baseline + and at the day's latest capture narrow the split, and a capture after the day + whose login time is still before it makes the day, Sunday included, 0. - 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 From 826145d1a2dd3c4cba8243a35d86f9af5363a6e7 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:03:21 +0900 Subject: [PATCH 16/98] Label game-zone times when the phone clock reads differently Membership date and time pickers, the Platoon member list and member details show a server-time caption whenever the game reset zone's offset differs from the device's, so a boundary is not entered hours off. Findings: q-time-boundaries-4 --- .../dev/gf2log/app/DateTimePickerInput.kt | 6 ++++ .../dev/gf2log/app/MemberDetailActivity.kt | 1 + .../java/dev/gf2log/app/PlatoonActivity.kt | 6 ++++ app/src/main/java/dev/gf2log/app/ui/Labels.kt | 14 +++++++++ app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../java/dev/gf2log/app/ui/ZoneCaptionTest.kt | 31 +++++++++++++++++++ 7 files changed, 60 insertions(+) create mode 100644 app/src/test/java/dev/gf2log/app/ui/ZoneCaptionTest.kt diff --git a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt index e6f911c..418a215 100644 --- a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt +++ b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt @@ -63,6 +63,12 @@ internal class DateTimePickerInput( addView(context.text(label, TextRole.LABEL) { setPadding(context.dp(4), 0, context.dp(4), context.dp(4)) }, context.lp()) + // The fields edit in the game reset zone; say so whenever the phone clock reads differently. + Labels.zoneCaption(context, zone)?.let { caption -> + addView(context.text(caption, TextRole.CAPTION) { + setPadding(context.dp(4), 0, context.dp(4), context.dp(4)) + }, context.lp()) + } addView(context.row { if (stacksPickerFields(context.resources.configuration.fontScale)) { // Side by side, large digits would not fit a narrow dialog; each field gets the row. diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index fff0e11..b20a6af 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -198,6 +198,7 @@ class MemberDetailActivity : BaseActivity() { } }, lp(top = 12)) } + Labels.zoneCaption(this, loaded.zone)?.let { statusPanel.addView(text(it, TextRole.CAPTION), lp(top = 12)) } val rows = status.membershipPeriods.mapIndexed { index, period -> navigationRow( title = getString(R.string.membership_period_title, index + 1) + diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index de5e9ec..9b92f9e 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -64,6 +64,7 @@ class PlatoonActivity : BaseActivity() { private lateinit var searchInput: EditText private lateinit var sortButton: TextView private lateinit var resultCount: TextView + private lateinit var zoneCaption: TextView private var model: Model? = null private var filter = Filter.ACTIVE @@ -210,6 +211,7 @@ class PlatoonActivity : BaseActivity() { resultCount = text(null, TextRole.LABEL) { accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE } + zoneCaption = text(null, TextRole.CAPTION) { visibility = View.GONE } summary = row(Gravity.TOP) updateSortLabel() return column { @@ -228,11 +230,15 @@ class PlatoonActivity : BaseActivity() { addView(resultCount, lp(width = 0, weight = 1f, start = 4)) addView(sortButton, lp(WRAP, WRAP)) }, lp(top = 4, bottom = 4)) + addView(zoneCaption, lp(start = 4, end = 4, bottom = 4)) } } private fun renderSummary(loaded: Model) { summary.removeAllViews() + val caption = Labels.zoneCaption(this, loaded.zone) + zoneCaption.text = caption + zoneCaption.visibility = if (caption == null) View.GONE else View.VISIBLE val active = loaded.statuses.count(MemberStatus::isActive) val tiles = listOf( getString(R.string.stat_active) to active.toString(), diff --git a/app/src/main/java/dev/gf2log/app/ui/Labels.kt b/app/src/main/java/dev/gf2log/app/ui/Labels.kt index 8f91e18..49f7072 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Labels.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Labels.kt @@ -33,6 +33,20 @@ internal object Labels { fun format(formatter: DateTimeFormatter, value: TemporalAccessor): String = formatter.format(value) + /** + * "Server time · Asia/Seoul" while [gameZone] is not the device's current offset, so a time + * shown or edited in the game reset zone is never mistaken for a phone-clock time. + */ + fun zoneCaption( + context: Context, + gameZone: ZoneId, + deviceZone: ZoneId = ZoneId.systemDefault(), + now: Instant = Instant.now(), + ): String? = if (sameOffset(gameZone, deviceZone, now)) null else context.getString(R.string.server_time_zone, gameZone.id) + + fun sameOffset(first: ZoneId, second: ZoneId, now: Instant): Boolean = + first.rules.getOffset(now) == second.rules.getOffset(now) + /** "10/12 Sun" with the weekday in the active app language. */ fun dayHeader(context: Context, day: java.time.LocalDate): String = "${day.monthValue}/${day.dayOfMonth} " + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index bd32cd9..becb4b6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -499,6 +499,7 @@ 저장하지 않은 변경 %1$s – %2$s 초기화 05:00 · %1$s + 서버 시간 · %1$s %1$s, %2$s %1$s: %2$s #%1$d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bec9adc..b9db3ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -505,6 +505,7 @@ Unsaved changes %1$s – %2$s Reset 05:00 · %1$s + Server time · %1$s %1$s, %2$s %1$s: %2$s #%1$d diff --git a/app/src/test/java/dev/gf2log/app/ui/ZoneCaptionTest.kt b/app/src/test/java/dev/gf2log/app/ui/ZoneCaptionTest.kt new file mode 100644 index 0000000..bae646f --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/ui/ZoneCaptionTest.kt @@ -0,0 +1,31 @@ +package dev.gf2log.app.ui + +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ZoneCaptionTest { + private val now = Instant.parse("2026-11-06T00:00:00Z") + + @Test + fun `a game zone with another offset than the phone needs a caption`() { + assertFalse(Labels.sameOffset(ZoneId.of("Asia/Seoul"), ZoneOffset.UTC, now)) + assertFalse(Labels.sameOffset(ZoneId.of("Asia/Seoul"), ZoneId.of("America/New_York"), now)) + } + + @Test + fun `a game zone on the phone's offset reads like the phone clock`() { + assertTrue(Labels.sameOffset(ZoneId.of("Asia/Seoul"), ZoneId.of("Asia/Tokyo"), now)) + assertTrue(Labels.sameOffset(ZoneId.of("UTC"), ZoneOffset.UTC, now)) + } + + @Test + fun `daylight saving is judged at the moment shown`() { + val summer = Instant.parse("2026-07-01T00:00:00Z") + assertTrue(Labels.sameOffset(ZoneId.of("Europe/London"), ZoneOffset.UTC, now)) + assertFalse(Labels.sameOffset(ZoneId.of("Europe/London"), ZoneOffset.UTC, summer)) + } +} From 9c94cb14e38b5040491d381283d802308d7a9136 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:03:46 +0900 Subject: [PATCH 17/98] Stop treating a near-reset Gunsmoke capture as a closed opening anchor An uncapped capture shortly before the Gunsmoke Sunday's 05:00 reset can miss Saturday's last login or Daily Patrol. The anchor is now closed only at the reset itself, with the capped preceding-week counter, or after Saturday's first Daily Patrol. Finding: q-weekly-engine-2 --- .../app/management/GunsmokeWeekSolver.kt | 27 ++++++--- .../app/management/GunsmokeWeekSolverTest.kt | 59 ++++++++++++++++++- 2 files changed, 78 insertions(+), 8 deletions(-) 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 e4541a7..6cfc955 100644 --- a/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt @@ -2,7 +2,6 @@ package dev.gf2log.app.management import dev.gf2log.app.management.ActivityInference.saturatedAdd import dev.gf2log.app.management.ActivityInference.saturatedMultiply -import java.time.Duration import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -158,6 +157,7 @@ internal object GunsmokeWeekSolver { zoneId = zoneId, periodStart = periodStart, cell = cells[0], + factsByDay = factsByDay, ).filter { state -> matchesCapturedEvidence( state = state, @@ -311,6 +311,7 @@ internal object GunsmokeWeekSolver { zoneId = zoneId, periodStart = periodStart, cell = cells[runStart], + factsByDay = factsByDay, ) var partials = initialStates.asSequence() @@ -953,6 +954,7 @@ internal object GunsmokeWeekSolver { zoneId: ZoneId, periodStart: Instant, cell: WeeklyReportBuilder.DayCell, + factsByDay: Map>, ): List { val direct = cell.meritDelta?.let { merit -> cell.scoreDelta?.let { score -> Metrics(merit, score) } @@ -969,6 +971,8 @@ internal object GunsmokeWeekSolver { weeklyMerit = anchor.member.weeklyMerit, zoneId = zoneId, periodStart = periodStart, + previousDayPatrolAt = factsByDay[PlatoonPeriods.gameDay(periodStart, zoneId).minusDays(1)] + ?.minOfOrNull(DailyPatrolFact::occurredAt), ) && checkpoint.member.totalMerit >= anchor.member.totalMerit ) { @@ -994,25 +998,35 @@ internal object GunsmokeWeekSolver { } /** - * Accepts a near-reset capture or a preceding-week counter that has already reached its cap. A - * capped Monday-through-Saturday counter cannot gain more merit before the Gunsmoke Sunday. - * Returns true when no unobserved pre-boundary merit can contaminate the opening interval. + * Accepts a capture at the reset itself, a preceding-week counter that has already reached its + * cap, or a capture on the Saturday before after that Saturday's first Daily Patrol at + * [previousDayPatrolAt]. A capped Monday-through-Saturday counter, like a Saturday that has + * completed its Daily Patrol, cannot gain more merit before the Gunsmoke Sunday; a capture + * merely close to 05:00 still can. Returns true when no unobserved pre-boundary merit can + * contaminate the opening interval. */ internal fun isClosedOpeningAnchor( capturedAt: Instant, weeklyMerit: Long, zoneId: ZoneId, periodStart: Instant, + previousDayPatrolAt: Instant? = null, ): Boolean { val precedingCounterStart = periodStart.atZone(zoneId).minusDays(6).toInstant() + val previousDayStart = periodStart.atZone(zoneId).minusDays(1).toInstant() return !capturedAt.isAfter(periodStart) && ( - Duration.between(capturedAt, periodStart) <= MAX_BOUNDARY_DISTANCE || + capturedAt == periodStart || ( weeklyMerit == MAX_PRE_GUNSMOKE_WEEKLY_MERIT && !capturedAt.isBefore(precedingCounterStart) + ) || + ( + previousDayPatrolAt != null && + !previousDayPatrolAt.isBefore(previousDayStart) && + !previousDayPatrolAt.isAfter(capturedAt) ) - ) + ) } private fun statesForMetrics( @@ -1849,7 +1863,6 @@ internal object GunsmokeWeekSolver { .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 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 2c3a56c..0f896cb 100644 --- a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt @@ -33,8 +33,9 @@ class GunsmokeWeekSolverTest { periodStart = periodStart, ), ) + // Close to 05:00 is not closed: Saturday can still earn its login or Daily Patrol. assertEquals( - true, + false, GunsmokeWeekSolver.isClosedOpeningAnchor( capturedAt = periodStart.minusSeconds(10 * 60), weeklyMerit = 90, @@ -44,6 +45,62 @@ class GunsmokeWeekSolverTest { ) } + @Test + fun uncappedOpeningAnchorIsClosedOnlyAtTheResetOrAfterSaturdaysDailyPatrol() { + val periodStart = start.atTime(5, 0).atZone(zone).toInstant() + val anchor = periodStart.minusSeconds(5 * 60) + + fun closed(capturedAt: Instant, patrolAt: Instant?) = GunsmokeWeekSolver.isClosedOpeningAnchor( + capturedAt = capturedAt, + weeklyMerit = 500, + zoneId = zone, + periodStart = periodStart, + previousDayPatrolAt = patrolAt, + ) + + assertEquals(true, closed(periodStart, null)) + assertEquals(false, closed(anchor, null)) + assertEquals(true, closed(anchor, periodStart.minusSeconds(6 * 60 * 60))) + // A Daily Patrol after the anchor is exactly the tail the anchor misses. + assertEquals(false, closed(anchor, periodStart.minusSeconds(3 * 60))) + // A Friday capture misses all of Saturday. + assertEquals(false, closed(periodStart.minusSeconds(26 * 60 * 60), periodStart.minusSeconds(27 * 60 * 60))) + } + + @Test + fun nearResetUncappedAnchorDoesNotCreditTheSaturdayTailToTheGunsmokeSunday() { + // Sunday 04:55 with 500 weekly merit, then Saturday's Daily Patrol at 04:57 (+40). Sunday + // itself is a login and one 1,000-point attempt: 50 + 30 + 100 = 180. + fun sunday(tail: Long, facts: List) = WeeklyReportBuilder.build( + referenceDay = start, + zoneId = zone, + snapshots = listOf( + PlatoonSnapshot(1, Instant.parse("2026-07-18T19:55:00Z"), listOf(member(10_000, 0, 500))), + PlatoonSnapshot( + 2, + Instant.parse("2026-07-19T19:55:00Z"), + listOf(member(10_180 + tail, 1_000, 680 + tail)), + ), + PlatoonSnapshot(3, Instant.parse("2026-07-20T19:55:00Z"), listOf(member(10_180 + tail, 1_000, 0))), + ), + dailyPatrolFacts = facts, + asOf = Instant.parse("2026-07-20T21:00:00Z"), + ).members.single().days.first() + + val contaminated = sunday(tail = 40, facts = listOf(DailyPatrolFact(UID, Instant.parse("2026-07-18T19:57:00Z")))) + assertEquals( + false, + contaminated.meritDelta == 220L && contaminated.meritCertainty == MetricCertainty.EXACT, + ) + assertEquals(false, contaminated.dailyPatrol == true) + + // A Daily Patrol before the anchor completes Saturday, so the anchor closes the interval. + val completed = sunday(tail = 0, facts = listOf(DailyPatrolFact(UID, Instant.parse("2026-07-18T03:00:00Z")))) + assertEquals(180L, completed.meritDelta) + assertEquals(MetricCertainty.EXACT, completed.meritCertainty) + assertEquals(1, completed.attempts) + } + @Test fun cappedOpeningAnchorUsesCalendarBoundariesAcrossDaylightSavingChanges() { val daylightZone = ZoneId.of("America/New_York") From 6271275d4dcba5f1f9465e4fc2b94de3e6598058 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:04:47 +0900 Subject: [PATCH 18/98] Deliver every flow close and date held payloads by their arrival Flow closes now bypass the chunk budget, so a closed flow always releases its parser, metadata and pre-identity payloads. A flow whose open was refused is tainted instead of later failing client verification. Before identity, only roster, activity and updates payloads can reject a flow; other payloads are held for enabled history under their own budget or discarded, so a flow of no Platoon no longer warns about an overflow. Payloads keep the time they arrived through admission and replay, so a roster confirmed after the reset is still dated before it. Findings: data-capture-handoffs-2, data-capture-handoffs-3, data-capture-handoffs-4, data-capture-handoffs-m3, q-data-3 --- .../app/capture/BoundedFlowPayloadBuffer.kt | 51 +++++-- .../gf2log/app/capture/CaptureFlowEngine.kt | 124 ++++++++++++++---- .../app/capture/GuildMembersCsvWriter.kt | 12 +- .../capture/PendingPlatoonAdmissionStore.kt | 11 +- .../capture/PlatoonAdmissionCoordinator.kt | 15 ++- .../app/capture/PlatoonCaptureSession.kt | 8 +- .../app/capture/PlatoonPayloadDispatcher.kt | 23 ++-- .../capture/BoundedFlowPayloadBufferTest.kt | 55 +++++++- .../app/capture/CaptureFlowIntakeTest.kt | 82 ++++++++++++ .../app/capture/GuildMembersCsvWriterTest.kt | 18 +++ .../PendingPlatoonAdmissionStoreTest.kt | 14 ++ .../capture/PlatoonPayloadDispatcherTest.kt | 28 ++-- .../app/capture/PreIdentityPolicyTest.kt | 57 ++++++++ docs/ARCHITECTURE.md | 20 ++- 14 files changed, 438 insertions(+), 80 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/capture/CaptureFlowIntakeTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/capture/PreIdentityPolicyTest.kt diff --git a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt index 8658dcc..c7e71df 100644 --- a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt +++ b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt @@ -2,15 +2,18 @@ package dev.gf2log.app.capture /** * Bounded pre-identity quarantine; overflow permanently rejects that flow until closure. + * Optional items have budgets of their own and are only dropped at their limit, so they can + * neither reject a flow nor take room from the items that would. * Capture confines it to the worker thread; access stays serialized so sharing it remains safe. */ internal class BoundedFlowPayloadBuffer( private val maxItemsPerFlow: Int, private val maxTotalItems: Int, ) { - private val pending = mutableMapOf>() + private val pending = mutableMapOf>() private val rejected = mutableSetOf() private var totalItems = 0 + private var totalOptionalItems = 0 init { require(maxItemsPerFlow > 0) @@ -20,21 +23,33 @@ internal class BoundedFlowPayloadBuffer( @Synchronized fun offer(flowId: Long, item: T): OfferResult { if (flowId in rejected) return OfferResult.REJECTED - val items = pending.getOrPut(flowId, ::ArrayDeque) - if (items.size >= maxItemsPerFlow || totalItems >= maxTotalItems) { + val items = pending.getOrPut(flowId, ::Pending) + if (items.required >= maxItemsPerFlow || totalItems >= maxTotalItems) { reject(flowId) return OfferResult.OVERFLOW } - items.addLast(item) + items.items.addLast(item) + items.required += 1 totalItems += 1 return OfferResult.ACCEPTED } + /** Holds [item] in captured order when there is room, without ever rejecting the flow. */ + @Synchronized + fun offerOptional(flowId: Long, item: T): OfferResult { + if (flowId in rejected) return OfferResult.REJECTED + val optional = pending[flowId]?.optional ?: 0 + if (optional >= maxItemsPerFlow || totalOptionalItems >= maxTotalItems) return OfferResult.DROPPED + val items = pending.getOrPut(flowId, ::Pending) + items.items.addLast(item) + totalOptionalItems += 1 + return OfferResult.ACCEPTED + } + @Synchronized fun take(flowId: Long): List { - val items = pending.remove(flowId) ?: return emptyList() - totalItems -= items.size - return items.toList() + val items = pending.remove(flowId)?.also(::release) ?: return emptyList() + return items.items.toList() } @Synchronized @@ -42,13 +57,13 @@ internal class BoundedFlowPayloadBuffer( @Synchronized fun reject(flowId: Long) { - totalItems -= pending.remove(flowId)?.size ?: 0 + pending.remove(flowId)?.let(::release) rejected += flowId } @Synchronized fun remove(flowId: Long) { - totalItems -= pending.remove(flowId)?.size ?: 0 + pending.remove(flowId)?.let(::release) rejected.remove(flowId) } @@ -57,7 +72,23 @@ internal class BoundedFlowPayloadBuffer( pending.clear() rejected.clear() totalItems = 0 + totalOptionalItems = 0 + } + + /** Items currently held across every flow, required and optional. */ + @Synchronized + fun size(): Int = totalItems + totalOptionalItems + + private fun release(items: Pending) { + totalItems -= items.required + totalOptionalItems -= items.optional + } + + private class Pending { + val items = ArrayDeque() + var required = 0 + val optional: Int get() = items.size - required } - enum class OfferResult { ACCEPTED, OVERFLOW, REJECTED } + enum class OfferResult { ACCEPTED, OVERFLOW, REJECTED, DROPPED } } diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt index 70bf453..e9732e0 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt @@ -15,6 +15,7 @@ import dev.gf2log.protocol.model.ParseEvent import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonProfileData import java.io.File +import java.time.Instant import java.util.concurrent.ConcurrentHashMap /** @@ -30,15 +31,20 @@ internal class CaptureFlowEngine( private val clientServerRegions: ClientServerRegionPreferences, private val payloadHistory: PayloadHistoryPreferences, ) : PlatoonAdmissionCoordinator.Flows { - /** The native thread taints a flow whose chunk was dropped; it stays ignored until closure. */ + /** The native thread taints a flow whose open or chunk was dropped; it stays ignored until closure. */ private val taintedFlows: MutableSet = ConcurrentHashMap.newKeySet() + private val intake = CaptureFlowIntake(worker, taintedFlows) { + CaptureStatus.report(CaptureNotice.PARSER_OVERLOADED, stats.recordDropped().toString()) + } private val parsers = HashMap() private val metadata = HashMap() private val sessions = HashMap() - private val preIdentity = BoundedFlowPayloadBuffer( + private val preIdentity = BoundedFlowPayloadBuffer( MAX_PRE_IDENTITY_PAYLOADS_PER_FLOW, MAX_PRE_IDENTITY_PAYLOADS_TOTAL, ) + /** Flows that already reported losing pre-identity history, so each reports it once. */ + private val historyDroppedFlows = HashSet() private val historyStores = HashMap() private val observedProfiles = HashMap>() private var ownerResolver = CaptureFlowOwnerResolver(context) @@ -60,6 +66,7 @@ internal class CaptureFlowEngine( metadata.clear() taintedFlows.clear() preIdentity.clear() + historyDroppedFlows.clear() observedProfiles.clear() tracker = null } @@ -73,18 +80,18 @@ internal class CaptureFlowEngine( remoteAddress: String, remotePort: Int, ) { - submit { open(flowId, protocol, localAddress, localPort, remoteAddress, remotePort) } + intake.opened(flowId) { open(flowId, protocol, localAddress, localPort, remoteAddress, remotePort) } } override fun onPayload(flowId: Long, payload: ByteArray) { - if (payload.isEmpty() || flowId in taintedFlows) return - // A missing TCP chunk makes every later byte offset unreliable. - if (!submit { accept(flowId, payload) }) taintedFlows += flowId + if (payload.isEmpty()) return + // Stamped on arrival, so a backlog or a pending admission never shifts when it was captured. + val receivedAt = Instant.now() + intake.payload(flowId) { accept(flowId, payload, receivedAt) } } override fun onFlowClosed(flowId: Long) { - // Earlier chunks may still be queued; the taint keeps them inert until session end. - if (!submit { close(flowId) }) taintedFlows += flowId + intake.closed(flowId) { close(flowId) } } override fun onTraffic(sentBytes: Long, receivedBytes: Long, inspectedBytes: Long) { @@ -123,10 +130,10 @@ internal class CaptureFlowEngine( observedProfiles.remove(storageId) } - override fun route(session: PlatoonCaptureSession, payload: ParsedPayload) { + override fun route(session: PlatoonCaptureSession, payload: ParsedPayload, receivedAt: Instant) { val storageId = session.profile.storageId saveHistory(storageId, payload) - val routed = session.dispatch(payload) + val routed = session.dispatch(payload, receivedAt) routed.activity ?.onSuccess { if (it) tracker?.mark(storageId, Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) } ?.onFailure { CaptureStatus.report(CaptureNotice.ACTIVITY_SAVE_FAILED) } @@ -149,12 +156,6 @@ internal class CaptureFlowEngine( } } - private fun submit(task: () -> Unit): Boolean = worker.submitChunk(task).also { queued -> - if (!queued) { - CaptureStatus.report(CaptureNotice.PARSER_OVERLOADED, stats.recordDropped().toString()) - } - } - private fun open( flowId: Long, protocol: Int, @@ -180,23 +181,24 @@ internal class CaptureFlowEngine( ) } - private fun accept(flowId: Long, payload: ByteArray) { + private fun accept(flowId: Long, payload: ByteArray, receivedAt: Instant) { if (flowId in taintedFlows) return - process(flowId, parsers.getOrPut(flowId, ::Gfl2StreamParser).accept(payload)) + process(flowId, parsers.getOrPut(flowId, ::Gfl2StreamParser).accept(payload), receivedAt) } private fun close(flowId: Long) { val parser = parsers.remove(flowId) if (!taintedFlows.remove(flowId) && parser != null) { - process(flowId, parser.finish()) + process(flowId, parser.finish(), Instant.now()) } metadata.remove(flowId) admission.flowClosed(flowId) preIdentity.remove(flowId) + historyDroppedFlows.remove(flowId) sessions.remove(flowId) } - private fun process(flowId: Long, events: List) { + private fun process(flowId: Long, events: List, receivedAt: Instant) { if (events.isEmpty()) return val payloads = mutableListOf() var warnings = 0 @@ -214,9 +216,9 @@ internal class CaptureFlowEngine( if (profile != null && PlatoonProfilePolicy.isValid(profile)) identify(flowId, profile) val session = sessions[flowId] if (session == null) { - holdUntilIdentified(flowId, payload) + holdUntilIdentified(flowId, ReceivedPayload(payload, receivedAt)) } else { - route(session, payload) + route(session, payload, receivedAt) } } } @@ -257,13 +259,14 @@ internal class CaptureFlowEngine( if (current != null) return val session = bind(flowId, profile) - preIdentity.take(flowId).forEach { pending -> route(session, pending) } + preIdentity.take(flowId).forEach { pending -> route(session, pending.payload, pending.receivedAt) } CaptureStatus.report( CaptureNotice.PLATOON_DETECTED, profile.platoonName.take(PlatoonAdmissionCoordinator.MAX_STATUS_NAME_CHARS), profile.platoonId.toString(), profile.client.displayName, ) + if (historyDroppedFlows.remove(flowId)) CaptureStatus.report(CaptureNotice.HISTORY_SAVE_FAILED) } private fun findKnownProfile( @@ -292,10 +295,20 @@ internal class CaptureFlowEngine( return profile } - private fun holdUntilIdentified(flowId: Long, payload: ParsedPayload) { - if (admission.retain(flowId, payload)) return - if (preIdentity.offer(flowId, payload) == BoundedFlowPayloadBuffer.OfferResult.OVERFLOW) { - CaptureStatus.report(CaptureNotice.FLOW_BUFFER_OVERFLOW) + private fun holdUntilIdentified(flowId: Long, received: ReceivedPayload) { + if (admission.retain(flowId, received)) return + val type = received.payload.payloadType + when (PreIdentityPolicy.retention(type, payloadHistory.isEnabled(type))) { + PreIdentityPolicy.Retention.REQUIRED -> + if (preIdentity.offer(flowId, received) == BoundedFlowPayloadBuffer.OfferResult.OVERFLOW) { + CaptureStatus.report(CaptureNotice.FLOW_BUFFER_OVERFLOW) + } + // Reported only if the flow is identified: a flow of no Platoon had no history to lose. + PreIdentityPolicy.Retention.HISTORY_ONLY -> + if (preIdentity.offerOptional(flowId, received) == BoundedFlowPayloadBuffer.OfferResult.DROPPED) { + historyDroppedFlows += flowId + } + PreIdentityPolicy.Retention.NONE -> Unit } } @@ -320,3 +333,60 @@ internal class CaptureFlowEngine( const val MAX_PRE_IDENTITY_PAYLOADS_TOTAL = 128 } } + +/** + * The native thread's side of [CaptureFlowEngine]: queues each flow callback on the worker. + * Opens and chunks share the worker's chunk budget, and a flow that loses either is tainted: a + * lost open leaves it without a verified owner, a lost chunk breaks its byte offsets. A close + * bypasses the budget, so it always releases the flow's state, after the flow's queued chunks. + */ +internal class CaptureFlowIntake( + private val worker: CaptureWorker, + private val taintedFlows: MutableSet, + private val onChunkDropped: () -> Unit, +) { + fun opened(flowId: Long, open: () -> Unit) { + if (!submit(open)) taintedFlows += flowId + } + + fun payload(flowId: Long, accept: () -> Unit) { + if (flowId in taintedFlows) return + if (!submit(accept)) taintedFlows += flowId + } + + /** Only a worker that has shut down refuses it; the taint then keeps the flow inert. */ + fun closed(flowId: Long, close: () -> Unit) { + if (!worker.execute(close)) taintedFlows += flowId + } + + private fun submit(task: () -> Unit): Boolean = + worker.submitChunk(task).also { queued -> if (!queued) onChunkDropped() } +} + +/** Which payloads of a flow that has no identity yet are worth holding until it has one. */ +internal object PreIdentityPolicy { + enum class Retention { + /** Platoon data that is lost unless held; failing to hold it rejects the flow. */ + REQUIRED, + + /** Kept for parsed-packet history only while there is room; never rejects the flow. */ + HISTORY_ONLY, + + /** Nothing would use it after identification. */ + NONE, + } + + private val PLATOON_DATA_TYPES = setOf( + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, + ) + + fun retention(payloadType: Int, historyEnabled: Boolean): Retention = when { + payloadType in PLATOON_DATA_TYPES -> Retention.REQUIRED + // A 21905 that reaches the buffer could not identify the flow, so it names no Platoon. + payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> Retention.NONE + historyEnabled -> Retention.HISTORY_ONLY + else -> Retention.NONE + } +} 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 46cf00a..62a60ad 100644 --- a/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt +++ b/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt @@ -26,8 +26,12 @@ class GuildMembersCsvWriter( private val publisher: (File, File) -> Unit = AtomicFiles::move, private val onBatchClosed: (CompletedBatch) -> Unit = {}, ) { + /** + * [receivedAt] names the file and becomes every row's `logTime`: a roster held for admission + * is dated when it arrived, not when it was confirmed. + */ @Synchronized - fun accept(payload: ParsedPayload): SaveResult? { + fun accept(payload: ParsedPayload, receivedAt: Instant = Instant.now(clock)): SaveResult? { val data = payload.data as? GuildMembersData ?: return null if (payload.messageId == 0 || !payload.isEndOfMessage) return null val members = linkedMapOf() @@ -38,11 +42,11 @@ class GuildMembersCsvWriter( check(GuildMembersCsv.hasValidMemberBounds(roster)) { "Platoon roster exceeds the member or name limit" } - return publish(roster) + return publish(roster, receivedAt) } - private fun publish(members: List): SaveResult { - val batch = reserve(Instant.now(clock), members) + private fun publish(members: List, receivedAt: Instant): SaveResult { + val batch = reserve(receivedAt, members) try { batch.workingFile.bufferedWriter(Charsets.UTF_8).use { writer -> writer.appendLine(GuildMembersCsv.HEADER) 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 a36d04f..349bc14 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt @@ -32,6 +32,8 @@ internal object PendingPlatoonAdmissionStore { data class BufferedPayload( val flowId: Long, val payload: ParsedPayload, + /** When it arrived; a confirmation hours later still dates what it records by this. */ + val receivedAt: Instant, ) data class Claim( @@ -127,7 +129,12 @@ internal object PendingPlatoonAdmissionStore { } @Synchronized - fun offer(token: String, flowId: Long, payload: ParsedPayload): OfferResult { + fun offer( + token: String, + flowId: Long, + payload: ParsedPayload, + receivedAt: Instant = Instant.now(), + ): OfferResult { val candidate = candidates[token] ?: return OfferResult.Missing if (candidate.claimed) return OfferResult.Claimed if ( @@ -137,7 +144,7 @@ internal object PendingPlatoonAdmissionStore { return OfferResult.Overflow(removeLocked(token)?.flowIds.orEmpty()) } candidate.flowIds += flowId - candidate.payloads += BufferedPayload(flowId, payload) + candidate.payloads += BufferedPayload(flowId, payload, receivedAt) totalPayloads += 1 return OfferResult.Accepted } 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 73066ff..7e50117 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt @@ -7,6 +7,7 @@ import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonProfileData +import java.time.Instant /** * Buffers a previously unknown Platoon identity in [PendingPlatoonAdmissionStore] and later @@ -23,7 +24,7 @@ internal class PlatoonAdmissionCoordinator( fun isOpen(flowId: Long): Boolean fun bind(flowId: Long, profile: PlatoonProfile): PlatoonCaptureSession fun unbind(flowId: Long, session: PlatoonCaptureSession) - fun route(session: PlatoonCaptureSession, payload: ParsedPayload) + fun route(session: PlatoonCaptureSession, payload: ParsedPayload, receivedAt: Instant) fun quarantine(flowId: Long) fun profileChanged(storageId: String) } @@ -57,9 +58,9 @@ internal class PlatoonAdmissionCoordinator( } /** Returns true when [payload] was consumed by the candidate [flowId] belongs to. */ - fun retain(flowId: Long, payload: ParsedPayload): Boolean { + fun retain(flowId: Long, received: ReceivedPayload): Boolean { val token = tokenByFlow[flowId] ?: return false - offer(token, flowId, payload) + offer(token, flowId, received) return true } @@ -67,7 +68,7 @@ internal class PlatoonAdmissionCoordinator( flowId: Long, ownerPackage: String, data: PlatoonProfileData, - preIdentityPayloads: List, + preIdentityPayloads: List, ) { val result = PendingPlatoonAdmissionStore.begin(ownerPackage, data, flowId) val token = result.token @@ -165,7 +166,7 @@ internal class PlatoonAdmissionCoordinator( return false } claim.payloads.forEach { buffered -> - runCatching { flows.route(sessions.getValue(buffered.flowId), buffered.payload) } + runCatching { flows.route(sessions.getValue(buffered.flowId), buffered.payload, buffered.receivedAt) } .onFailure { error -> CaptureStatus.report(CaptureNotice.PROCESSING_FAILED, error.javaClass.simpleName) } @@ -185,8 +186,8 @@ internal class PlatoonAdmissionCoordinator( } /** Returns false when the flow can no longer contribute to the candidate. */ - private fun offer(token: String, flowId: Long, payload: ParsedPayload): Boolean = - when (val result = PendingPlatoonAdmissionStore.offer(token, flowId, payload)) { + private fun offer(token: String, flowId: Long, received: ReceivedPayload): Boolean = + when (val result = PendingPlatoonAdmissionStore.offer(token, flowId, received.payload, received.receivedAt)) { PendingPlatoonAdmissionStore.OfferResult.Accepted -> true PendingPlatoonAdmissionStore.OfferResult.Claimed -> { CaptureStatus.report(CaptureNotice.ADMISSION_IN_PROGRESS) diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt index a24b833..ef7813d 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt @@ -21,11 +21,13 @@ internal class PlatoonCaptureSession( ) private val dispatcher = PlatoonPayloadDispatcher( onMembers = writer::accept, - onActivity = { repository.ingestActivity(it).acceptedObservations > 0 }, - onUpdates = { repository.ingestUpdates(it).acceptedObservations > 0 }, + onActivity = { data, receivedAt -> repository.ingestActivity(data, receivedAt).acceptedObservations > 0 }, + onUpdates = { data, receivedAt -> repository.ingestUpdates(data, receivedAt).acceptedObservations > 0 }, ) - fun dispatch(payload: ParsedPayload): PlatoonPayloadDispatcher.Results = dispatcher.dispatch(payload) + /** [receivedAt] dates what [payload] records, even when it was held until identity was known. */ + fun dispatch(payload: ParsedPayload, receivedAt: Instant): PlatoonPayloadDispatcher.Results = + dispatcher.dispatch(payload, receivedAt) private fun ingestCompletedRoster(batch: GuildMembersCsvWriter.CompletedBatch) { val directResult = runCatching { diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonPayloadDispatcher.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonPayloadDispatcher.kt index be06913..8d9ad5e 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonPayloadDispatcher.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonPayloadDispatcher.kt @@ -4,17 +4,24 @@ import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonActivityData import dev.gf2log.protocol.model.PlatoonUpdatesData +import java.time.Instant -/** Routes each Platoon payload to its handler; one handler's failure never suppresses another. */ +/** A decoded payload held for later routing, with the time it arrived from the network. */ +internal data class ReceivedPayload(val payload: ParsedPayload, val receivedAt: Instant) + +/** + * Routes each Platoon payload to its handler; one handler's failure never suppresses another. + * Every handler receives the payload's arrival time, which a held payload keeps until it is routed. + */ internal class PlatoonPayloadDispatcher( - private val onMembers: (ParsedPayload) -> GuildMembersCsvWriter.SaveResult?, - private val onActivity: (PlatoonActivityData) -> Boolean, - private val onUpdates: (PlatoonUpdatesData) -> Boolean, + private val onMembers: (ParsedPayload, Instant) -> GuildMembersCsvWriter.SaveResult?, + private val onActivity: (PlatoonActivityData, Instant) -> Boolean, + private val onUpdates: (PlatoonUpdatesData, Instant) -> Boolean, ) { - fun dispatch(payload: ParsedPayload): Results = Results( - members = payload.takeIf { it.data is GuildMembersData }?.let { runCatching { onMembers(it) } }, - activity = (payload.data as? PlatoonActivityData)?.let { runCatching { onActivity(it) } }, - updates = (payload.data as? PlatoonUpdatesData)?.let { runCatching { onUpdates(it) } }, + fun dispatch(payload: ParsedPayload, receivedAt: Instant): Results = Results( + members = payload.takeIf { it.data is GuildMembersData }?.let { runCatching { onMembers(it, receivedAt) } }, + activity = (payload.data as? PlatoonActivityData)?.let { runCatching { onActivity(it, receivedAt) } }, + updates = (payload.data as? PlatoonUpdatesData)?.let { runCatching { onUpdates(it, receivedAt) } }, ) data class Results( diff --git a/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt b/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt index f0603a9..44245e2 100644 --- a/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt @@ -1,7 +1,9 @@ package dev.gf2log.app.capture +import dev.gf2log.protocol.Gfl2PayloadDecoder import java.lang.reflect.Modifier import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -62,9 +64,60 @@ class BoundedFlowPayloadBufferTest { assertEquals(listOf("four"), buffer.take(4)) } + @Test + fun optionalItemsAreDroppedAtTheirLimitWithoutRejectingTheFlow() { + val buffer = BoundedFlowPayloadBuffer(2, 3) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offerOptional(1, "history-a")) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offerOptional(1, "history-b")) + assertEquals(2, buffer.size()) + + assertEquals(BoundedFlowPayloadBuffer.OfferResult.DROPPED, buffer.offerOptional(1, "history-c")) + assertFalse(buffer.isRejected(1)) + assertEquals(2, buffer.size()) + + // Optional items take no room from the items whose loss rejects a flow. + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(1, "roster-a")) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(1, "roster-b")) + assertEquals(listOf("history-a", "history-b", "roster-a", "roster-b"), buffer.take(1)) + assertEquals(0, buffer.size()) + } + + @Test + fun optionalItemsShareOnlyTheirOwnAggregateBudget() { + val buffer = BoundedFlowPayloadBuffer(4, 2) + buffer.offerOptional(1, "one") + buffer.offerOptional(2, "two") + assertEquals(BoundedFlowPayloadBuffer.OfferResult.DROPPED, buffer.offerOptional(3, "three")) + assertFalse(buffer.isRejected(3)) + + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(3, "roster")) + buffer.remove(1) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offerOptional(3, "three")) + assertEquals(listOf("roster", "three"), buffer.take(3)) + + buffer.reject(2) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.REJECTED, buffer.offerOptional(2, "late")) + assertEquals(0, buffer.size()) + } + + @Test + fun manyHistoryPayloadsBeforeIdentityNeverCostTheFlowItsPlatoonData() { + val buffer = BoundedFlowPayloadBuffer(32, 128) + repeat(40) { + val type = Gfl2PayloadDecoder.TYPE_FORMATIONS + if (PreIdentityPolicy.retention(type, historyEnabled = true) == PreIdentityPolicy.Retention.HISTORY_ONLY) { + buffer.offerOptional(1, type) + } + } + + assertFalse(buffer.isRejected(1)) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(1, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS)) + assertEquals(33, buffer.take(1).size) + } + @Test fun everyStatefulOperationIsSerializedAcrossParserAndCloseThreads() { - val synchronizedMethods = setOf("offer", "take", "isRejected", "reject", "remove", "clear") + val synchronizedMethods = setOf("offer", "offerOptional", "take", "isRejected", "reject", "remove", "clear", "size") val methodsByName = BoundedFlowPayloadBuffer::class.java.declaredMethods .filter { it.name in synchronizedMethods } .associateBy { it.name } diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureFlowIntakeTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureFlowIntakeTest.kt new file mode 100644 index 0000000..5ce9f10 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureFlowIntakeTest.kt @@ -0,0 +1,82 @@ +package dev.gf2log.app.capture + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class CaptureFlowIntakeTest { + private val worker = CaptureWorker("intake-test", chunkCapacity = 1) { throw AssertionError(it) } + private val tainted: MutableSet = ConcurrentHashMap.newKeySet() + private val order = Collections.synchronizedList(mutableListOf()) + private var dropped = 0 + private val intake = CaptureFlowIntake(worker, tainted) { dropped += 1 } + + @After + fun tearDown() = worker.shutdown() + + @Test + fun aCloseNeedsNoBudgetAndRunsAfterTheFlowsQueuedChunks() { + val gate = blockWorker() + intake.opened(7) { order += "open" } + intake.payload(7) { order += "chunk" } + assertEquals(1, dropped) + assertTrue(7L in tainted) + + intake.closed(7) { + order += "close" + tainted.remove(7) + } + assertEquals(1, dropped) + + gate.countDown() + drain() + assertEquals(listOf("open", "close"), order) + assertFalse(7L in tainted) + } + + @Test + fun aFlowWhoseOpenWasRefusedStaysInertUntilItCloses() { + val gate = blockWorker() + intake.payload(1) { order += "other" } + intake.opened(2) { order += "open" } + assertEquals(1, dropped) + assertTrue(2L in tainted) + + gate.countDown() + drain() + // Budget is free again, but the flow has no owner metadata, so its chunks are ignored. + intake.payload(2) { order += "chunk" } + drain() + assertEquals(listOf("other"), order) + + intake.closed(2) { tainted.remove(2) } + drain() + assertFalse(2L in tainted) + } + + @Test + fun onlyAShutDownWorkerLeavesAClosedFlowTainted() { + worker.shutdown() + intake.closed(3) { order += "close" } + assertTrue(3L in tainted) + assertTrue(order.isEmpty()) + } + + private fun blockWorker(): CountDownLatch { + val gate = CountDownLatch(1) + assertTrue(worker.execute { gate.await(5, TimeUnit.SECONDS) }) + return gate + } + + private fun drain() { + val drained = CountDownLatch(1) + assertTrue(worker.execute { drained.countDown() }) + assertTrue(drained.await(5, TimeUnit.SECONDS)) + } +} 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 7e075d5..9f2dc8b 100644 --- a/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt @@ -41,6 +41,24 @@ class GuildMembersCsvWriterTest { ) } + @Test + fun aRosterHeldForConfirmationIsDatedWhenItArrived() { + val output = temporaryFolder.newFolder("held") + // Confirmed after the 05:00 reset, received before it. + val clock = Clock.fixed(Instant.parse("2026-07-22T05:20:00Z"), ZoneOffset.UTC) + val completed = mutableListOf() + val writer = GuildMembersCsvWriter(output, clock, onBatchClosed = completed::add) + + val saved = writer.accept( + payload(42, end = true, member(123456u, "First Member")), + receivedAt = Instant.parse("2026-07-22T04:50:00Z"), + ) + + assertEquals("gf2log_platoonmembers_20260722T045000Z.csv", saved?.file?.name) + assertEquals("2026-07-22T04:50:00Z", completed.single().logTime) + assertTrue(requireNotNull(saved).file.readLines(Charsets.UTF_8).last().endsWith(",2026-07-22T04:50:00Z")) + } + @Test fun rosterCutShortByAnotherPayloadIsNeverMergedWithALaterRoster() { val output = temporaryFolder.newFolder("boundary") 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 9b65cd9..ae50e8d 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt @@ -109,6 +109,20 @@ class PendingPlatoonAdmissionStoreTest { assertTrue(PendingPlatoonAdmissionStore.summaries().none { it.token == token }) } + @Test + fun aClaimReplaysEachPayloadWithTheTimeItArrived() { + val profile = profile(4u, "Early") + val token = requireNotNull( + PendingPlatoonAdmissionStore.begin(SupportedGamePackages.HAOPLAY, profile, flowId = 3L).token, + ) + val arrived = Instant.parse("2026-07-22T04:50:00Z") + PendingPlatoonAdmissionStore.offer(token, 3L, payload(profile), arrived) + + val buffered = requireNotNull(PendingPlatoonAdmissionStore.claim(token)).payloads.single() + assertEquals(arrived, buffered.receivedAt) + assertEquals(3L, buffered.flowId) + } + @Test fun failedOrThrowingAdmissionReleasesTheClaimForAnotherDecision() { val token = requireNotNull( diff --git a/app/src/test/java/dev/gf2log/app/capture/PlatoonPayloadDispatcherTest.kt b/app/src/test/java/dev/gf2log/app/capture/PlatoonPayloadDispatcherTest.kt index 082a5f0..64cb0ba 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PlatoonPayloadDispatcherTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PlatoonPayloadDispatcherTest.kt @@ -13,23 +13,29 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.time.Instant class PlatoonPayloadDispatcherTest { @Test fun routesEachRequiredPayloadOnlyToItsHandler() { val routed = mutableListOf() val dispatcher = PlatoonPayloadDispatcher( - onMembers = { _ -> routed += "members"; null }, - onActivity = { routed += "activity"; true }, - onUpdates = { routed += "updates"; true }, + onMembers = { _, at -> routed += "members@$at"; null }, + onActivity = { _, at -> routed += "activity@$at"; true }, + onUpdates = { _, at -> routed += "updates@$at"; true }, ) - val members = dispatcher.dispatch(membersPayload()) - val activity = dispatcher.dispatch(activityPayload()) - val updates = dispatcher.dispatch(updatesPayload()) + // Each handler dates its record by when the payload arrived, however late it is routed. + val members = dispatcher.dispatch(membersPayload(), Instant.parse("2026-07-22T04:50:00Z")) + val activity = dispatcher.dispatch(activityPayload(), Instant.parse("2026-07-22T04:51:00Z")) + val updates = dispatcher.dispatch(updatesPayload(), Instant.parse("2026-07-22T04:52:00Z")) assertEquals( - listOf("members", "activity", "updates"), + listOf( + "members@2026-07-22T04:50:00Z", + "activity@2026-07-22T04:51:00Z", + "updates@2026-07-22T04:52:00Z", + ), routed, ) assertTrue(members.members?.getOrThrow() == null) @@ -45,12 +51,12 @@ class PlatoonPayloadDispatcherTest { fun handlerFailureIsReturnedInsteadOfThrown() { val routed = mutableListOf() val dispatcher = PlatoonPayloadDispatcher( - onMembers = { _ -> routed += "members"; null }, - onActivity = { routed += "activity"; error("database unavailable") }, - onUpdates = { routed += "updates"; true }, + onMembers = { _, _ -> routed += "members"; null }, + onActivity = { _, _ -> routed += "activity"; error("database unavailable") }, + onUpdates = { _, _ -> routed += "updates"; true }, ) - val result = dispatcher.dispatch(activityPayload()) + val result = dispatcher.dispatch(activityPayload(), Instant.EPOCH) assertEquals(listOf("activity"), routed) assertTrue(result.activity?.isFailure == true) diff --git a/app/src/test/java/dev/gf2log/app/capture/PreIdentityPolicyTest.kt b/app/src/test/java/dev/gf2log/app/capture/PreIdentityPolicyTest.kt new file mode 100644 index 0000000..3211ac1 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/PreIdentityPolicyTest.kt @@ -0,0 +1,57 @@ +package dev.gf2log.app.capture + +import dev.gf2log.app.capture.PreIdentityPolicy.Retention +import dev.gf2log.protocol.Gfl2PayloadDecoder +import org.junit.Assert.assertEquals +import org.junit.Test + +class PreIdentityPolicyTest { + @Test + fun platoonDataIsAlwaysHeldUntilIdentity() { + listOf( + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, + ).forEach { type -> + assertEquals(Retention.REQUIRED, PreIdentityPolicy.retention(type, historyEnabled = true)) + assertEquals(Retention.REQUIRED, PreIdentityPolicy.retention(type, historyEnabled = false)) + } + } + + @Test + fun otherPayloadsAreHeldOnlyForEnabledHistory() { + assertEquals( + Retention.HISTORY_ONLY, + PreIdentityPolicy.retention(Gfl2PayloadDecoder.TYPE_FORMATIONS, historyEnabled = true), + ) + assertEquals( + Retention.NONE, + PreIdentityPolicy.retention(Gfl2PayloadDecoder.TYPE_WEAPONS, historyEnabled = false), + ) + } + + @Test + fun aProfileThatCouldNotIdentifyTheFlowIsNeverHeld() { + // An account in no Platoon sends 21905 with Platoon ID 0 on every login. + assertEquals( + Retention.NONE, + PreIdentityPolicy.retention(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, historyEnabled = true), + ) + } + + @Test + fun aFlowOfNoPlatoonNeverFillsTheBufferOrWarns() { + val buffer = BoundedFlowPayloadBuffer(32, 128) + val types = listOf(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) + List(40) { Gfl2PayloadDecoder.TYPE_WEAPONS } + val overflows = types.count { type -> + when (PreIdentityPolicy.retention(type, historyEnabled = false)) { + Retention.REQUIRED -> buffer.offer(1, type) == BoundedFlowPayloadBuffer.OfferResult.OVERFLOW + Retention.HISTORY_ONLY -> buffer.offerOptional(1, type) == BoundedFlowPayloadBuffer.OfferResult.OVERFLOW + Retention.NONE -> false + } + } + + assertEquals(0, overflows) + assertEquals(0, buffer.size()) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae40198..eb7be83 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,10 +116,13 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. Quarantine is advanced from frame metadata before protobuf decoding, so even a malformed terminal fragment clears the discarded dataset deterministically. - One serial `CaptureWorker` owns every parser, flow, session, and admission - structure, so that state needs no locks. Native callbacks share a budget of - 256 queued chunks; lifecycle, confirm, and discard tasks bypass that budget - and are never rejected, so a pending Platoon can be confirmed or discarded - after capture stops. `CaptureFlowEngine` routes payloads, + structure, so that state needs no locks. Native flow opens and chunks share + a budget of 256 queued chunks, and a flow that loses either is ignored until + it closes; flow closes, lifecycle, confirm, and discard tasks bypass that + budget and are never rejected, so every closed flow releases its state and a + pending Platoon can be confirmed or discarded after capture stops. Each + payload keeps the time it arrived, so one held for identity or confirmation + is still dated by that time. `CaptureFlowEngine` routes payloads, `PlatoonAdmissionCoordinator` owns pending admission, and the service is lifecycle glue whose fields are main-thread only. - Stopping capture never blocks the main thread: the worker stops the native @@ -128,9 +131,12 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. 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. +- Before identity is established, each flow retains at most 32 roster, + activity, and updates payloads (128 across flows); overflow rejects that flow + until closure. Other payloads are held only for enabled parsed-packet history, + under a separate budget of the same size that drops instead of rejecting, and + are otherwise discarded. The profile registry holds at most 16 bounded + identities. - Unconfirmed profile admission is capped at 4 candidates, 64 decoded payloads per candidate, and 128 payloads total. Overflow rejects every flow belonging to that candidate rather than partially persisting it. From 28ba1be054b1fe61fba81258cff75cc9924a8f2a Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:05:06 +0900 Subject: [PATCH 19/98] Refuse to capture while a CSV import recovery is incomplete A scope whose interrupted import or undo could not be rolled back would later be restored over whatever capture ingested meanwhile. Capture start now fails with the history import notice instead, and retries recovery on the next start. Findings: data-lifecycle-races-m1, data-sqlite-corruption-poison-3 --- .../gf2log/app/capture/CaptureVpnService.kt | 21 +++++++++--- .../capture/CaptureHistoryPreparationTest.kt | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/capture/CaptureHistoryPreparationTest.kt 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 4e272a9..08dc295 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -186,10 +186,10 @@ class CaptureVpnService : VpnService() { worker.execute { val migration = runCatching { 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 -> + CaptureHistoryPreparation.run( + scopes, + recover = { CsvImportCheckpointManager.recoverInterruptedImports(this, it) }, + ) { scope -> GuildMembersCsvWriter.deleteStalePartials(scope.retainedCsvDirectory(this)) PlatoonRepository(this, scope).reconcileRetainedCsvFiles() } @@ -349,3 +349,16 @@ class CaptureVpnService : VpnService() { private var teardownsInFlight = 0 } } + +/** + * Readies every scope's retained history before a capture may ingest into it. An interrupted CSV + * import or undo must finish first, or its later recovery would overwrite what this capture + * ingests, so one scope whose [recover]y failed fails the whole start; it is retried next time. + */ +internal object CaptureHistoryPreparation { + fun run(scopes: List, recover: (List) -> Int, prepare: (S) -> Unit) { + val failed = recover(scopes) + check(failed == 0) { "CSV import recovery is incomplete for $failed Platoon scope(s)" } + scopes.forEach(prepare) + } +} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureHistoryPreparationTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureHistoryPreparationTest.kt new file mode 100644 index 0000000..ef6729a --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureHistoryPreparationTest.kt @@ -0,0 +1,34 @@ +package dev.gf2log.app.capture + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CaptureHistoryPreparationTest { + @Test + fun everyScopeIsPreparedOnceRecoveryIsComplete() { + val prepared = mutableListOf() + + CaptureHistoryPreparation.run(listOf("a", "b"), recover = { 0 }) { prepared += it } + + assertEquals(listOf("a", "b"), prepared) + } + + @Test + fun anIncompleteRecoveryFailsTheStartBeforeAnythingIsCaptured() { + val prepared = mutableListOf() + var recovered: List? = null + + // A scope whose interrupted import could not be rolled back would later be restored over + // whatever this capture ingested, so the start must fail rather than capture into it. + val failure = runCatching { + CaptureHistoryPreparation.run(listOf("a", "b"), recover = { scopes -> recovered = scopes; 1 }) { + prepared += it + } + }.exceptionOrNull() + + assertTrue(failure is IllegalStateException) + assertEquals(listOf("a", "b"), recovered) + assertTrue(prepared.isEmpty()) + } +} From a72126df2bd3fd8c1fe90c82818925a83581bf6c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:05:16 +0900 Subject: [PATCH 20/98] Word English capture counts so they read correctly for one Findings: ui-status-render-9 --- app/src/main/res/values/strings_capture.xml | 6 +++--- .../app/capture/CaptureNoticeStringsTest.kt | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/capture/CaptureNoticeStringsTest.kt diff --git a/app/src/main/res/values/strings_capture.xml b/app/src/main/res/values/strings_capture.xml index f3cd7ae..5f0c0e3 100644 --- a/app/src/main/res/values/strings_capture.xml +++ b/app/src/main/res/values/strings_capture.xml @@ -12,9 +12,9 @@ Capture stopped unexpectedly. Start capture again to retry. Captured Platoon roster, activity, and updates Captured Platoon roster; missing %1$s after the navigation period - Forwarded %1$s KiB; inspected %2$s KiB; decoded %3$s payloads; warnings %4$s; dropped %5$s + Forwarded %1$s KiB; inspected %2$s KiB; payloads decoded: %3$s; warnings: %4$s; dropped: %5$s Protocol processing failed: %1$s - Parser overloaded; dropped %1$s queued chunks + Parser overloaded; queued chunks dropped: %1$s Discarded an unidentified Platoon flow that exceeded its buffer Discarded an identified flow whose pre-identity buffer overflowed Detected a Platoon profile, but its game client could not be verified @@ -33,7 +33,7 @@ Unable to apply the confirmed Platoon packets Confirmed %1$s (%2$s) via %3$s Discarded the unconfirmed Platoon packets - Saved %1$s members for %2$s + Saved the %2$s roster (members: %1$s) Unable to save Platoon CSV Updated %1$s: +%2$s, -%3$s Recovered %1$s from the completed roster CSV diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureNoticeStringsTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureNoticeStringsTest.kt new file mode 100644 index 0000000..de88422 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureNoticeStringsTest.kt @@ -0,0 +1,18 @@ +package dev.gf2log.app.capture + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class CaptureNoticeStringsTest { + @Test + fun englishNoticesNeverPutACountBeforeAPluralNoun() { + // Unit tests run from the module directory. + val strings = File("src/main/res/values/strings_capture.xml").readText(Charsets.UTF_8) + // "Saved 1 members" or "decoded 1 payloads": a count argument followed by a plural noun. + val countedPlural = Regex("""%\d+\${'$'}s\s+(?:queued\s+)?(?:members|payloads|chunks|warnings|items)\b""") + + val offenders = countedPlural.findAll(strings).map { it.value }.toList() + assertTrue("Reword without a counted plural: $offenders", offenders.isEmpty()) + } +} From e75a224717ded1cc0420202d877a57e2d37ae90d Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:05:16 +0900 Subject: [PATCH 21/98] Keep capture stop reasons and alerts visible and announced Traffic counters no longer replace a warning or error and appear only while running. During teardown, drained work cannot replace the stop reason unless it is more severe, and a second stop without a reason keeps the first one. A revoked VPN now says so, and standby records it as missing VPN consent. A partial one-time capture names its missing items with the checklist labels, and one completed at the grace deadline ends as complete. The checklist shows only while capturing or beside its outcome; its rows are built once and updated in place, and each new warning or error is announced once. Findings: ui-status-render-1, ui-status-render-2, ui-status-render-3, ui-status-render-m1, ui-status-render-6, ui-status-render-7, ui-status-render-8, ui-a11y-fontscale-4 --- .../java/dev/gf2log/app/HomeCapturePanel.kt | 73 ++++++--- .../gf2log/app/capture/CaptureRunTracker.kt | 34 +++- .../dev/gf2log/app/capture/CaptureStatus.kt | 76 ++++++++- .../gf2log/app/capture/CaptureVpnService.kt | 7 +- .../gf2log/app/standby/StandbyAutomation.kt | 8 +- .../app/capture/CaptureRunTrackerTest.kt | 38 ++++- .../gf2log/app/capture/CaptureStatusTest.kt | 155 ++++++++++++++++++ 7 files changed, 343 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt b/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt index d6c81de..855dc47 100644 --- a/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt +++ b/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt @@ -2,6 +2,7 @@ package dev.gf2log.app import android.content.Context import android.content.pm.PackageManager +import android.content.res.ColorStateList import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Build @@ -9,11 +10,15 @@ import android.text.TextUtils import android.view.Gravity import android.view.View import android.widget.LinearLayout +import androidx.annotation.StringRes import dev.gf2log.app.capture.CaptureNotice +import dev.gf2log.app.capture.CaptureNoticeEvent import dev.gf2log.app.capture.CapturePhase +import dev.gf2log.app.capture.CaptureRunTracker import dev.gf2log.app.capture.CaptureSnapshot import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.capture.format +import dev.gf2log.app.capture.isNewAlert import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.TextRole import dev.gf2log.app.ui.WRAP @@ -26,7 +31,6 @@ import dev.gf2log.app.ui.lp import dev.gf2log.app.ui.panel import dev.gf2log.app.ui.row import dev.gf2log.app.ui.text -import dev.gf2log.protocol.Gfl2PayloadDecoder /** * Home's capture panel: the capture phase and its latest notice, the guided checklist, installed @@ -51,9 +55,14 @@ internal class HomeCapturePanel( ellipsize = android.text.TextUtils.TruncateAt.END } private val checklist = context.column { visibility = View.GONE } + // Built once and updated in place, so frequent renders never detach a row TalkBack is on. + private val checklistRows = CaptureRunTracker.CHECKLIST_LABELS.map { (type, label) -> + ChecklistRow(context, type, label).also { checklist.addView(it.row, context.lp()) } + } private val captureActions = context.row(Gravity.TOP) private val clients = context.column() private var lastPhase: CapturePhase? = null + private var lastAnnounced: CaptureNoticeEvent? = null val view: LinearLayout = context.panel(outlined = true) { addView(context.row { @@ -127,13 +136,21 @@ internal class HomeCapturePanel( ), ) val notice = snapshot.notice - stateDetail.text = when { + val detail = when { 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) } + if (detail != stateDetail.text.toString()) { + // Only a new warning or error is announced; routine progress such as traffic is not. + val announce = notice.isNewAlert(lastAnnounced) + if (announce) lastAnnounced = notice + stateDetail.accessibilityLiveRegion = + if (announce) View.ACCESSIBILITY_LIVE_REGION_POLITE else View.ACCESSIBILITY_LIVE_REGION_NONE + stateDetail.text = detail + } stateDetail.setTextColor( getColor( when (notice?.notice?.severity) { @@ -143,7 +160,7 @@ internal class HomeCapturePanel( }, ), ) - renderChecklist(snapshot.guided) + renderChecklist(snapshot.visibleGuided) if (phase != lastPhase) { lastPhase = phase renderActions(phase) @@ -165,31 +182,33 @@ internal class HomeCapturePanel( else -> packageName } - private fun renderChecklist(guided: CaptureStatus.GuidedProgress?) = with(context) { - checklist.removeAllViews() + private fun renderChecklist(guided: CaptureStatus.GuidedProgress?) { checklist.visibility = if (guided == null) View.GONE else View.VISIBLE - guided ?: return@with - listOf( - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE to R.string.checklist_identity, - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS to R.string.checklist_roster, - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY to R.string.checklist_activity, - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES to R.string.checklist_updates, - ).forEach { (type, label) -> - val done = type in guided.capturedPayloadTypes - checklist.addView(row { - minimumHeight = dp(32) - isFocusable = true - contentDescription = getString(label) + ". " + getString(if (done) R.string.captured else R.string.waiting) - addView(icon( - if (done) R.drawable.ic_check_circle else R.drawable.ic_circle_outline, - if (done) R.color.success_mark else R.color.outline_strong, - sizeDp = 20, - ), lp(20, 20, end = 10)) - addView(text(getString(label), TextRole.BODY) { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - if (!done) setTextColor(getColor(R.color.text_secondary)) - }, lp(width = 0, weight = 1f)) - }, lp()) + guided ?: return + checklistRows.forEach { it.render(it.type in guided.capturedPayloadTypes) } + } + + /** One checklist row; [render] touches its views only when its state changes. */ + private class ChecklistRow(private val context: Context, val type: Int, @StringRes private val label: Int) { + private val mark = context.icon(R.drawable.ic_circle_outline, R.color.outline_strong, sizeDp = 20) + private val name = context.text(context.getString(label), TextRole.BODY) { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + private var done: Boolean? = null + val row: LinearLayout = context.row { + minimumHeight = context.dp(32) + isFocusable = true + addView(mark, context.lp(20, 20, end = 10)) + addView(name, context.lp(width = 0, weight = 1f)) + } + + fun render(done: Boolean) = with(context) { + if (this@ChecklistRow.done == done) return@with + this@ChecklistRow.done = done + row.contentDescription = getString(label) + ". " + getString(if (done) R.string.captured else R.string.waiting) + mark.setImageResource(if (done) R.drawable.ic_check_circle else R.drawable.ic_circle_outline) + mark.imageTintList = ColorStateList.valueOf(getColor(if (done) R.color.success_mark else R.color.outline_strong)) + name.setTextColor(getColor(if (done) TextRole.BODY.color else R.color.text_secondary)) } } diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt index 0bf8a33..3264748 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt @@ -1,7 +1,7 @@ package dev.gf2log.app.capture +import dev.gf2log.app.R import dev.gf2log.protocol.Gfl2PayloadDecoder -import dev.gf2log.protocol.PayloadCatalog /** * One capture run's required-payload checklist. Marks arrive on the capture worker; a one-time @@ -27,19 +27,35 @@ internal class CaptureRunTracker( if (captureOnce && checklist.targetScopeId() == storageId) onTargetRosterCaptured(this) } - /** Tags the target scope still lacks, or null until that scope's roster was captured. */ - fun missingAfterRoster(): List? { + /** Payload types the target scope still lacks, or null until that scope's roster was captured. */ + fun missingAfterRoster(): List? { val captured = checklist.targetCaptured() if (Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS !in captured) return null - return REQUIRED_TYPES.minus(captured).map(PayloadCatalog::tag) + return CHECKLIST_LABELS.keys.filterNot(captured::contains) + } + + /** + * The stop notice and its args once the grace period after the target roster ends, or null + * before that roster. A checklist completed right at the deadline still reads as complete. + */ + fun graceOutcome(): Pair>? { + val missing = missingAfterRoster() ?: return null + return if (missing.isEmpty()) { + CaptureNotice.CAPTURE_ONCE_COMPLETE to emptyList() + } else { + CaptureNotice.CAPTURE_ONCE_PARTIAL to missing.map(Int::toString) + } } companion object { - val REQUIRED_TYPES = setOf( - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, + /** Required payload types in checklist order, each with the label Home shows for it. */ + val CHECKLIST_LABELS: Map = linkedMapOf( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE to R.string.checklist_identity, + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS to R.string.checklist_roster, + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY to R.string.checklist_activity, + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES to R.string.checklist_updates, ) + + val REQUIRED_TYPES: Set = CHECKLIST_LABELS.keys } } 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 b595982..afb4cc0 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt @@ -28,7 +28,7 @@ enum class CaptureNotice(@StringRes val messageRes: Int, val severity: Severity) NATIVE_START_FAILED(R.string.capture_notice_native_start_failed, Severity.ERROR), STOPPED_UNEXPECTEDLY(R.string.capture_notice_stopped_unexpectedly, Severity.ERROR), CAPTURE_ONCE_COMPLETE(R.string.capture_notice_capture_once_complete, Severity.INFO), - /** Args: comma-separated missing payload tags. */ + /** Args: each missing payload type, named by its checklist label when formatted. */ CAPTURE_ONCE_PARTIAL(R.string.capture_notice_capture_once_partial, Severity.WARNING), /** Args: forwarded KiB, inspected KiB, decoded payloads, warnings, dropped chunks. */ TRAFFIC(R.string.capture_notice_traffic, Severity.INFO), @@ -81,7 +81,25 @@ data class CaptureNoticeEvent( ) fun CaptureNoticeEvent.format(context: Context): String = - context.getString(notice.messageRes, *args.toTypedArray()) + context.getString( + notice.messageRes, + *displayArgs { type -> CaptureRunTracker.CHECKLIST_LABELS[type]?.let(context::getString) }.toTypedArray(), + ) + +/** + * [args] as the message shows them. A partial capture stores payload types, not text, so its + * missing items are named in the current language by the checklist's own [label]s. + */ +internal fun CaptureNoticeEvent.displayArgs(label: (Int) -> String?): List = + if (notice != CaptureNotice.CAPTURE_ONCE_PARTIAL) { + args + } else { + listOf(args.joinToString(", ") { arg -> arg.toIntOrNull()?.let(label) ?: arg }) + } + +/** True for a warning or error other than [lastAnnounced], so each one is announced only once. */ +internal fun CaptureNoticeEvent?.isNewAlert(lastAnnounced: CaptureNoticeEvent?): Boolean = + this != null && notice.severity != CaptureNotice.Severity.INFO && this != lastAnnounced data class CaptureSnapshot( val phase: CapturePhase, @@ -90,7 +108,24 @@ data class CaptureSnapshot( val guided: CaptureStatus.GuidedProgress?, /** Increases each time capture reaches [CapturePhase.RUNNING], so a new run is distinguishable. */ val runCount: Long = 0, -) +) { + /** + * The one-time checklist while capture starts or runs, and afterwards only beside the outcome + * it explains. A failed or user-stopped run hides it, so nothing reads as still waiting. + */ + val visibleGuided: CaptureStatus.GuidedProgress? + get() = guided?.takeIf { + phase == CapturePhase.STARTING || + phase == CapturePhase.RUNNING || + notice?.notice == CaptureNotice.CAPTURE_ONCE_COMPLETE || + notice?.notice == CaptureNotice.CAPTURE_ONCE_PARTIAL + } + + /** True once capture ended because Android revoked the VPN, for example for another VPN app. */ + val endedByVpnRevoke: Boolean + get() = (phase == CapturePhase.STOPPING || phase == CapturePhase.STOPPED) && + notice?.notice == CaptureNotice.VPN_PERMISSION_REVOKED +} /** Process-wide capture state, swapped atomically as one immutable [CaptureSnapshot]. */ object CaptureStatus { @@ -128,7 +163,7 @@ object CaptureStatus { internal fun markStopped(notice: CaptureNotice? = null, vararg args: String) = update { it.copy( phase = CapturePhase.STOPPED, - notice = notice?.let { value -> event(value, args) }, + notice = stopNotice(it, notice, args), capturedTargets = emptyList(), ) } @@ -136,7 +171,7 @@ object CaptureStatus { internal fun markStopping(notice: CaptureNotice?, vararg args: String) = update { it.copy( phase = CapturePhase.STOPPING, - notice = notice?.let { value -> event(value, args) }, + notice = stopNotice(it, notice, args), capturedTargets = emptyList(), ) } @@ -179,7 +214,22 @@ object CaptureStatus { } internal fun report(notice: CaptureNotice, vararg args: String) = update { - it.copy(notice = event(notice, args)) + if (shows(it, notice)) it.copy(notice = event(notice, args)) else it + } + + /** + * Whether a reported [notice] may replace what [current] shows. Traffic counters are routine + * progress: they appear only while running and never hide a warning or error. While stopping, + * work drained from the queue cannot replace why capture stopped unless it is more severe. + */ + internal fun shows(current: CaptureSnapshot, notice: CaptureNotice): Boolean { + val shown = current.notice?.notice?.severity ?: CaptureNotice.Severity.INFO + return when { + notice == CaptureNotice.TRAFFIC -> + current.phase == CapturePhase.RUNNING && shown == CaptureNotice.Severity.INFO + current.phase == CapturePhase.STOPPING -> notice.severity > shown + else -> true + } } internal fun beginSession(guided: Boolean) = update { @@ -211,6 +261,20 @@ object CaptureStatus { } } + /** + * A stop without a reason, such as a second stop request, keeps the reason an earlier stop + * already published; stopping a starting or running capture clears its progress notice. + */ + private fun stopNotice( + current: CaptureSnapshot, + notice: CaptureNotice?, + args: Array, + ): CaptureNoticeEvent? = when { + notice != null -> event(notice, args) + current.phase == CapturePhase.STOPPING || current.phase == CapturePhase.STOPPED -> current.notice + else -> null + } + private fun event(notice: CaptureNotice, args: Array = emptyArray()) = CaptureNoticeEvent(notice, args.toList(), System.currentTimeMillis()) 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 08dc295..22f1e5a 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -50,8 +50,8 @@ class CaptureVpnService : VpnService() { private var destroyed = false private val captureOnceGraceStop = Runnable { - val missing = run?.takeIf { state == State.RUNNING }?.missingAfterRoster() ?: return@Runnable - stop(CaptureNotice.CAPTURE_ONCE_PARTIAL, missing.joinToString()) + val (notice, args) = run?.takeIf { state == State.RUNNING }?.graceOutcome() ?: return@Runnable + stop(notice, *args.toTypedArray()) } override fun onCreate() { @@ -99,7 +99,8 @@ class CaptureVpnService : VpnService() { } override fun onRevoke() { - stop() + // Another VPN app took over or the user disconnected this one in system settings. + stop(CaptureNotice.VPN_PERMISSION_REVOKED) super.onRevoke() } 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 f6050e3..7e75af2 100644 --- a/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt +++ b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt @@ -160,7 +160,13 @@ internal object StandbyAutomation { // without closing anything or locking the phone in their hand. userLeftGame(now) -> cancel() CaptureStatus.snapshot().phase.let { it == CapturePhase.STOPPING || it == CapturePhase.STOPPED } -> - endRun(StandbyOutcome.CAPTURE_FAILED) + endRun( + if (CaptureStatus.snapshot().endedByVpnRevoke) { + StandbyOutcome.NO_VPN_CONSENT + } else { + 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. diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt index 7527b1f..9eac69d 100644 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt @@ -40,19 +40,53 @@ class CaptureRunTrackerTest { tracker.rosterCaptured("first") assertEquals(1, rosters) - assertEquals(listOf("ACTIVITY", "UPDATES"), tracker.missingAfterRoster()) + assertEquals( + listOf(Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES), + tracker.missingAfterRoster(), + ) assertEquals(0, completed) tracker.mark("first", Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) tracker.mark("first", Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES) assertEquals(1, completed) - assertEquals(emptyList(), tracker.missingAfterRoster()) + assertEquals(emptyList(), tracker.missingAfterRoster()) assertEquals( CaptureRunTracker.REQUIRED_TYPES, CaptureStatus.snapshot().guided?.capturedPayloadTypes, ) } + @Test + fun theGracePeriodReportsWhatIsMissingAsPayloadTypes() { + val tracker = CaptureRunTracker(captureOnce = true) + assertNull(tracker.graceOutcome()) + + tracker.rosterCaptured("first") + tracker.mark("first", Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) + + assertEquals( + CaptureNotice.CAPTURE_ONCE_PARTIAL to listOf( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE.toString(), + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES.toString(), + ), + tracker.graceOutcome(), + ) + } + + @Test + fun aChecklistCompletedAtTheGraceDeadlineEndsAsComplete() { + val tracker = CaptureRunTracker(captureOnce = true) + tracker.rosterCaptured("first") + listOf( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, + ).forEach { tracker.mark("first", it) } + + // The grace stop can run before the completion it raced; it must not read as partial. + assertEquals(CaptureNotice.CAPTURE_ONCE_COMPLETE to emptyList(), tracker.graceOutcome()) + } + @Test fun continuousCaptureMarksEveryScopeWithoutStopping() { var completed = 0 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 c1b425e..efba7a8 100644 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt @@ -3,6 +3,7 @@ package dev.gf2log.app.capture import dev.gf2log.protocol.Gfl2PayloadDecoder import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -16,6 +17,8 @@ class CaptureStatusTest { fun setUp() { MainThread.testExecutor = { it() } CaptureStatus.markStopped() + // A stop without a reason keeps an earlier test's stop notice. + CaptureStatus.clearIdleNotice() CaptureStatus.beginSession(guided = false) } @@ -100,6 +103,158 @@ class CaptureStatusTest { assertEquals(CaptureNotice.STOPPED_UNEXPECTEDLY, stopped.notice?.notice) } + @Test + fun trafficNeverHidesAWarningOrErrorAndAppearsOnlyWhileRunning() { + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.report(CaptureNotice.TRAFFIC, "64", "0", "1", "0", "0") + assertEquals(CaptureNotice.TRAFFIC, CaptureStatus.snapshot().notice?.notice) + + CaptureStatus.report(CaptureNotice.ROSTER_SAVE_FAILED) + CaptureStatus.report(CaptureNotice.TRAFFIC, "128", "0", "1", "0", "0") + assertEquals(CaptureNotice.ROSTER_SAVE_FAILED, CaptureStatus.snapshot().notice?.notice) + + CaptureStatus.report(CaptureNotice.PARSER_OVERLOADED, "1") + CaptureStatus.report(CaptureNotice.TRAFFIC, "192", "0", "1", "0", "1") + assertEquals(CaptureNotice.PARSER_OVERLOADED, CaptureStatus.snapshot().notice?.notice) + + CaptureStatus.markStopping(notice = null) + CaptureStatus.report(CaptureNotice.TRAFFIC, "256", "0", "1", "0", "1") + assertNull(CaptureStatus.snapshot().notice) + + CaptureStatus.markTornDown() + CaptureStatus.report(CaptureNotice.TRAFFIC, "320", "0", "1", "0", "1") + assertNull(CaptureStatus.snapshot().notice) + } + + @Test + fun workDrainedDuringTeardownKeepsTheStopReasonUnlessItIsWorse() { + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.markStopping(CaptureNotice.CAPTURE_ONCE_COMPLETE) + CaptureStatus.report(CaptureNotice.ROSTER_SAVED, "3", "P") + CaptureStatus.report(CaptureNotice.TRAFFIC, "64", "0", "1", "0", "0") + CaptureStatus.markTornDown() + assertEquals(CaptureNotice.CAPTURE_ONCE_COMPLETE, CaptureStatus.snapshot().notice?.notice) + + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.markStopping(CaptureNotice.STOPPED_UNEXPECTEDLY) + CaptureStatus.report(CaptureNotice.PLATOON_DETECTED, "P", "1", "HaoPlay") + CaptureStatus.report(CaptureNotice.FLOW_BUFFER_OVERFLOW) + assertEquals(CaptureNotice.STOPPED_UNEXPECTEDLY, CaptureStatus.snapshot().notice?.notice) + + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.markStopping(CaptureNotice.CAPTURE_ONCE_COMPLETE) + CaptureStatus.report(CaptureNotice.ROSTER_SAVE_FAILED) + assertEquals(CaptureNotice.ROSTER_SAVE_FAILED, CaptureStatus.snapshot().notice?.notice) + + // Once stopped, an admission decision reports its outcome as before. + CaptureStatus.markTornDown() + CaptureStatus.report(CaptureNotice.PENDING_DISCARDED) + assertEquals(CaptureNotice.PENDING_DISCARDED, CaptureStatus.snapshot().notice?.notice) + } + + @Test + fun aSecondStopWithoutAReasonKeepsTheFirstStopsReason() { + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.markStopping(CaptureNotice.STOPPED_UNEXPECTEDLY) + CaptureStatus.markStopping(notice = null) + CaptureStatus.markTornDown() + assertEquals(CaptureNotice.STOPPED_UNEXPECTEDLY, CaptureStatus.snapshot().notice?.notice) + + CaptureStatus.markStopped() + assertEquals(CaptureNotice.STOPPED_UNEXPECTEDLY, CaptureStatus.snapshot().notice?.notice) + + // A stop that does give a reason replaces it. + CaptureStatus.markStopped(CaptureNotice.NATIVE_START_FAILED) + assertEquals(CaptureNotice.NATIVE_START_FAILED, CaptureStatus.snapshot().notice?.notice) + + // Stopping a running capture without a reason still clears its progress notice. + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.markStopping(notice = null) + assertNull(CaptureStatus.snapshot().notice) + } + + @Test + fun aRevokedVpnStaysRecognizableThroughTheStopThatFollows() { + CaptureStatus.markRunning(listOf("pkg")) + assertFalse(CaptureStatus.snapshot().endedByVpnRevoke) + + CaptureStatus.markStopping(CaptureNotice.VPN_PERMISSION_REVOKED) + assertTrue(CaptureStatus.snapshot().endedByVpnRevoke) + + // Standby's own stop request arrives while the revoked capture tears down. + CaptureStatus.markStopping(notice = null) + CaptureStatus.markTornDown() + CaptureStatus.markStopped() + assertTrue(CaptureStatus.snapshot().endedByVpnRevoke) + + CaptureStatus.markRunning(listOf("pkg")) + CaptureStatus.markStopping(CaptureNotice.STOPPED_UNEXPECTEDLY) + assertFalse(CaptureStatus.snapshot().endedByVpnRevoke) + } + + @Test + fun theGuidedChecklistShowsOnlyWhileCapturingOrBesideItsOutcome() { + val guided = CaptureStatus.GuidedProgress(setOf(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS)) + fun snapshot(phase: CapturePhase, notice: CaptureNotice?) = CaptureSnapshot( + phase = phase, + notice = notice?.let { CaptureNoticeEvent(it, atMillis = 0) }, + capturedTargets = emptyList(), + guided = guided, + ) + + assertEquals(guided, snapshot(CapturePhase.STARTING, CaptureNotice.PREPARING).visibleGuided) + assertEquals(guided, snapshot(CapturePhase.RUNNING, CaptureNotice.TRAFFIC).visibleGuided) + assertEquals(guided, snapshot(CapturePhase.STOPPED, CaptureNotice.CAPTURE_ONCE_PARTIAL).visibleGuided) + assertEquals(guided, snapshot(CapturePhase.STOPPING, CaptureNotice.CAPTURE_ONCE_COMPLETE).visibleGuided) + assertNull(snapshot(CapturePhase.STOPPED, notice = null).visibleGuided) + assertNull(snapshot(CapturePhase.STOPPED, CaptureNotice.NATIVE_START_FAILED).visibleGuided) + assertNull(snapshot(CapturePhase.STOPPING, CaptureNotice.VPN_PERMISSION_REVOKED).visibleGuided) + assertNull(snapshot(CapturePhase.RUNNING, CaptureNotice.TRAFFIC).copy(guided = null).visibleGuided) + } + + @Test + fun aPartialCaptureNamesItsMissingItemsWithTheChecklistLabels() { + val labels = mapOf( + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY to "Platoon activity", + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES to "Membership updates", + ) + val partial = CaptureNoticeEvent( + CaptureNotice.CAPTURE_ONCE_PARTIAL, + listOf( + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY.toString(), + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES.toString(), + ), + atMillis = 0, + ) + + assertEquals(listOf("Platoon activity, Membership updates"), partial.displayArgs(labels::get)) + // Every other notice shows its args unchanged. + val saved = CaptureNoticeEvent(CaptureNotice.ROSTER_SAVED, listOf("21935", "P"), atMillis = 0) + assertEquals(listOf("21935", "P"), saved.displayArgs(labels::get)) + } + + @Test + fun everyCheckedPayloadTypeHasALabel() { + assertEquals(CaptureRunTracker.REQUIRED_TYPES, CaptureRunTracker.CHECKLIST_LABELS.keys) + } + + @Test + fun eachWarningOrErrorIsAnnouncedOnceAndRoutineProgressNever() { + val failed = CaptureNoticeEvent(CaptureNotice.ROSTER_SAVE_FAILED, atMillis = 1) + val traffic = CaptureNoticeEvent(CaptureNotice.TRAFFIC, listOf("1", "0", "0", "0", "0"), atMillis = 2) + + assertTrue(failed.isNewAlert(lastAnnounced = null)) + assertFalse(failed.isNewAlert(lastAnnounced = failed)) + assertFalse(traffic.isNewAlert(lastAnnounced = failed)) + assertFalse((null as CaptureNoticeEvent?).isNewAlert(lastAnnounced = failed)) + // The same failure reported again is new. + assertTrue(failed.copy(atMillis = 3).isNewAlert(lastAnnounced = failed)) + assertTrue( + CaptureNoticeEvent(CaptureNotice.PARSER_OVERLOADED, listOf("1"), atMillis = 4) + .isNewAlert(lastAnnounced = failed), + ) + } + @Test fun teardownLeavesAPhaseANewerStartAlreadyOwns() { CaptureStatus.markRunning(listOf("pkg")) From b304bb17ab26cfb2328fa2c1c1e361142b5bb06c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:05:16 +0900 Subject: [PATCH 22/98] Open the app and offer Stop from the capture notification Findings: q-uiux-8 --- .../CaptureNotificationsIntegrationTest.kt | 35 ++++++++++++++ .../app/capture/CaptureNotifications.kt | 48 +++++++++++++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/dev/gf2log/app/capture/CaptureNotificationsIntegrationTest.kt diff --git a/app/src/androidTest/java/dev/gf2log/app/capture/CaptureNotificationsIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/capture/CaptureNotificationsIntegrationTest.kt new file mode 100644 index 0000000..1c1f130 --- /dev/null +++ b/app/src/androidTest/java/dev/gf2log/app/capture/CaptureNotificationsIntegrationTest.kt @@ -0,0 +1,35 @@ +package dev.gf2log.app.capture + +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.gf2log.app.MainActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CaptureNotificationsIntegrationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun tappingTheNotificationOpensTheAppLikeItsLauncherIcon() { + val intent = CaptureNotifications.openIntent(context) + + assertEquals(MainActivity::class.java.name, intent.component?.className) + assertEquals(Intent.ACTION_MAIN, intent.action) + assertTrue(intent.hasCategory(Intent.CATEGORY_LAUNCHER)) + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + } + + @Test + fun theStopActionTargetsOnlyTheCaptureService() { + val intent = CaptureNotifications.stopIntent(context) + + assertEquals(CaptureVpnService::class.java.name, intent.component?.className) + assertEquals(context.packageName, intent.component?.packageName) + assertEquals(CaptureVpnService.ACTION_STOP, intent.action) + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt index a046a5e..0042b21 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt @@ -3,14 +3,23 @@ package dev.gf2log.app.capture import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.Intent import android.content.pm.ServiceInfo +import android.graphics.drawable.Icon import android.os.Build import androidx.annotation.StringRes +import dev.gf2log.app.MainActivity import dev.gf2log.app.R import dev.gf2log.app.settings.CapturePreferences -/** The foreground notification Android requires while VPN capture is active. */ +/** + * The foreground notification Android requires while VPN capture is active. Tapping it opens the + * app, and its Stop action ends capture as Home's stop button does. + */ internal class CaptureNotifications(private val service: Service) { private val preferences = CapturePreferences(service) private val manager = service.getSystemService(NotificationManager::class.java) @@ -48,10 +57,41 @@ internal class CaptureNotifications(private val service: Service) { ), ) .setOngoing(true) + // Outside the app this notification is the capture's only control. + .setContentIntent( + PendingIntent.getActivity( + service, + REQUEST_OPEN, + openIntent(service), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) + .addAction( + Notification.Action.Builder( + Icon.createWithResource(service, R.drawable.ic_stop), + service.getString(R.string.stop_capture), + PendingIntent.getService( + service, + REQUEST_STOP, + stopIntent(service), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ).build(), + ) .build() - private companion object { - const val CHANNEL = "capture" - const val ID = 1 + internal companion object { + private const val CHANNEL = "capture" + private const val ID = 1 + private const val REQUEST_OPEN = 1 + private const val REQUEST_STOP = 2 + + /** Brings the app's task forward the way its launcher icon does. */ + fun openIntent(context: Context): Intent = + Intent.makeMainActivity(ComponentName(context, MainActivity::class.java)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + + fun stopIntent(context: Context): Intent = + Intent(context, CaptureVpnService::class.java).setAction(CaptureVpnService.ACTION_STOP) } } From 8184af95d2d49ab2536430dd867cbd9d37bf5c41 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:05:23 +0900 Subject: [PATCH 23/98] Never leave a failed export behind, and report outcomes after recreation A document the picker created is deleted when its export fails, is refused, or can no longer be written; a refused provider says which locations work. Toasts, the restore refresh and PNG cache cleanup now run once for every finished task even if the screen was recreated meanwhile. A refused VPN consent on Home explains why capture did not start. Findings: ui-process-death-results-1, ui-process-death-results-2, ui-process-death-results-3 --- .../main/java/dev/gf2log/app/BackupFlow.kt | 43 ++++--- .../main/java/dev/gf2log/app/CsvImportFlow.kt | 11 +- .../main/java/dev/gf2log/app/MainActivity.kt | 36 ++++-- .../java/dev/gf2log/app/PlatoonActivity.kt | 19 +++- .../java/dev/gf2log/app/WeeklyExportFlow.kt | 49 ++++++-- .../java/dev/gf2log/app/ui/BackgroundWork.kt | 26 ++++- .../java/dev/gf2log/app/ui/DocumentAccess.kt | 48 +++++++- app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../app/ui/BackgroundWorkDeliveryTest.kt | 31 +++++ .../dev/gf2log/app/ui/ExportDocumentTest.kt | 107 ++++++++++++++++++ 11 files changed, 319 insertions(+), 55 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/ui/BackgroundWorkDeliveryTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/ui/ExportDocumentTest.kt diff --git a/app/src/main/java/dev/gf2log/app/BackupFlow.kt b/app/src/main/java/dev/gf2log/app/BackupFlow.kt index 7744cf4..ff840f3 100644 --- a/app/src/main/java/dev/gf2log/app/BackupFlow.kt +++ b/app/src/main/java/dev/gf2log/app/BackupFlow.kt @@ -34,14 +34,18 @@ internal class BackupFlow( ActivityResultContracts.CreateDocument(PlatoonBackupManager.MIME_TYPE), ) { uri -> val target = scope() - if (uri == null || target == null) return@registerForActivityResult + if (uri == null) return@registerForActivityResult + if (target == null) { + work.run(task = { DocumentAccess.discard(activity.contentResolver, uri) }) + return@registerForActivityResult + } val complete = pendingExportComplete run( progress = R.string.backup_exporting, success = if (complete) R.string.full_backup_exported else R.string.status_backup_exported, - failure = { backupExportFailureMessage(complete) }, + failure = { DocumentAccess.exportFailureMessage(it, backupExportFailureMessage(complete)) }, ) { - DocumentAccess.openOutput(activity.contentResolver, uri).use { output -> + DocumentAccess.writeExport(activity.contentResolver, uri) { output -> val manager = PlatoonBackupManager(activity, target) if (complete) manager.exportFull(output) else manager.export(output) } @@ -86,27 +90,30 @@ internal class BackupFlow( PlatoonBackupManager.restoreSelected(activity, input) } }, - ) { result -> - result.fold( - onSuccess = { kind -> - Feedback.show( - activity, - if (kind == RestoredBackupKind.COMPLETE) R.string.full_backup_restored else R.string.status_backup_restored, - long = true, - ) - activity.recreate() - }, - onFailure = { Feedback.show(activity, fullBackupRestoreFailureMessage(it), long = true) }, - ) - } + // Runs even if this screen was recreated meanwhile; the showing screen reloads the data. + always = { result -> + result.fold( + onSuccess = { kind -> + Feedback.show( + activity, + if (kind == RestoredBackupKind.COMPLETE) R.string.full_backup_restored else R.string.status_backup_restored, + long = true, + ) + (activity.takeUnless { it.isFinishing || it.isDestroyed } ?: BaseActivity.foregroundScreen()) + ?.recreate() + }, + onFailure = { Feedback.show(activity, fullBackupRestoreFailureMessage(it), long = true) }, + ) + }, + ) } private fun run(progress: Int, success: Int, failure: (Throwable) -> Int, operation: () -> Unit) { if (work.isBusy(maintenanceKey)) return Feedback.show(activity, progress) - work.run(key = maintenanceKey, task = operation) { result -> + work.run(key = maintenanceKey, task = operation, always = { result -> Feedback.show(activity, result.fold(onSuccess = { success }, onFailure = failure), long = true) - } + }) } private companion object { diff --git a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt index 348bc21..0cbdf8e 100644 --- a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt +++ b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt @@ -275,10 +275,8 @@ internal class CsvImportFlow( Summary(retained, duplicates, imported) } }, - ) { result -> - onProgress(null) - pickerTarget = null - result.fold( + // The summary is reported even if the screen was recreated meanwhile. + always = { result -> result.fold( onSuccess = { summary -> Feedback.show( activity, @@ -293,7 +291,10 @@ internal class CsvImportFlow( ) }, onFailure = { Feedback.show(activity, R.string.status_platoon_csv_import_failed, long = true) }, - ) + ) }, + ) { + onProgress(null) + pickerTarget = null } } diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 64e4a72..21384d4 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -72,7 +72,13 @@ class MainActivity : BaseActivity() { private val vpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> startPending = false - if (result.resultCode == RESULT_OK) startCaptureService() else CaptureStatus.clearIdleNotice() + if (result.resultCode == RESULT_OK) { + startCaptureService() + } else { + CaptureStatus.clearIdleNotice() + // A consent screen that closes on its own (a managed or restricted device) is explained. + vpnConsentDeniedMessage(result.resultCode)?.let { Feedback.show(this, it, long = true) } + } } private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { @@ -80,21 +86,30 @@ class MainActivity : BaseActivity() { } private val exportRoster = registerForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { uri -> - val scope = profileBinding.scope ?: return@registerForActivityResult if (uri == null) return@registerForActivityResult + val scope = profileBinding.scope ?: run { + work.run(task = { DocumentAccess.discard(contentResolver, uri) }) + return@registerForActivityResult + } work.run( task = { val latest = requireNotNull(PlatoonCsvImportStore.latestRetainedFile(scope.retainedCsvDirectory(this))) val snapshot = requireNotNull(GuildMembersCsv.parse(latest.readText(Charsets.UTF_8))) { "Stored Platoon CSV is invalid" } - DocumentAccess.openOutput(contentResolver, uri).writer(Charsets.UTF_8).use { - it.write(GuildMembersCsv.formatForSpreadsheet(snapshot)) + DocumentAccess.writeExport(contentResolver, uri) { output -> + output.writer(Charsets.UTF_8).use { it.write(GuildMembersCsv.formatForSpreadsheet(snapshot)) } } }, - ) { result -> - Feedback.show(this, if (result.isSuccess) R.string.roster_csv_exported else R.string.status_export_failed) - } + always = { result -> + Feedback.show( + this, + result.fold({ R.string.roster_csv_exported }) { + DocumentAccess.exportFailureMessage(it, R.string.status_export_failed) + }, + ) + }, + ) } override fun onCreate(savedInstanceState: Bundle?) { @@ -319,3 +334,10 @@ class MainActivity : BaseActivity() { const val STATE_CAPTURE_ONCE = "home.capture_once" } } + +/** + * What Home says when VPN consent comes back without approval; every consent launcher explains a + * refusal the same way, so Start capture is never a silent no-op. + */ +internal fun vpnConsentDeniedMessage(resultCode: Int): Int? = + if (resultCode == android.app.Activity.RESULT_OK) null else R.string.capture_vpn_needed diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index 9b92f9e..27c17d4 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -78,16 +78,29 @@ class PlatoonActivity : BaseActivity() { ) { uri -> val uids = pendingExport?.toSet() pendingExport = null - if (uri == null || uids.isNullOrEmpty()) return@registerForActivityResult + if (uri == null) return@registerForActivityResult + if (uids.isNullOrEmpty()) { + work.run(task = { DocumentAccess.discard(contentResolver, uri) }) + return@registerForActivityResult + } work.run( task = { val statuses = repository.listMemberStatuses().filter { it.uid in uids } val latest = latestMembers(repository.listSnapshots(1).firstOrNull()) val csv = PlatoonMemberCsv.format(statuses, latest, repository.gameZone()) - DocumentAccess.openOutput(contentResolver, uri).writer(Charsets.UTF_8).use { it.write(csv) } + DocumentAccess.writeExport(contentResolver, uri) { output -> + output.writer(Charsets.UTF_8).use { it.write(csv) } + } + }, + always = { result -> + Feedback.show( + this, + result.fold({ R.string.members_exported }) { + DocumentAccess.exportFailureMessage(it, R.string.status_export_failed) + }, + ) }, ) { result -> - Feedback.show(this, if (result.isSuccess) R.string.members_exported else R.string.status_export_failed) if (result.isSuccess) setSelecting(false) } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt b/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt index 6ce701f..89aa111 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt @@ -39,7 +39,11 @@ internal class WeeklyExportFlow( private val csvDocument = activity.registerForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { uri -> val request = pendingCsv pendingCsv = null - if (uri == null || request == null) return@registerForActivityResult + if (uri == null) return@registerForActivityResult + if (request == null) { + work.run(task = { DocumentAccess.discard(activity.contentResolver, uri) }) + return@registerForActivityResult + } work.run( task = { val zone = GameTimeZonePreferences.get(activity, storageId()) @@ -49,11 +53,19 @@ internal class WeeklyExportFlow( // 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) } + DocumentAccess.writeExport(activity.contentResolver, uri) { output -> + output.writer(Charsets.UTF_8).use { it.write(csv) } + } }, - ) { result -> - Feedback.show(activity, if (result.isSuccess) R.string.weekly_csv_exported else R.string.weekly_csv_export_failed) - } + always = { result -> + Feedback.show( + activity, + result.fold({ R.string.weekly_csv_exported }) { + DocumentAccess.exportFailureMessage(it, R.string.weekly_csv_export_failed) + }, + ) + }, + ) } private val pngDocument = activity.registerForActivityResult(ActivityResultContracts.CreateDocument("image/png")) { uri -> @@ -61,18 +73,33 @@ internal class WeeklyExportFlow( pendingPng = null if (uri == null || source == null) { source?.delete() + if (uri != null) { + // The rendered image is gone, so the created document can never be filled. + work.run(task = { DocumentAccess.discard(activity.contentResolver, uri) }) + Feedback.show(activity, R.string.weekly_png_failed) + } return@registerForActivityResult } work.run( task = { - DocumentAccess.openOutput(activity.contentResolver, uri).use { output -> - source.inputStream().use { it.copyTo(output) } + try { + DocumentAccess.writeExport(activity.contentResolver, uri) { output -> + source.inputStream().use { it.copyTo(output) } + } + } finally { + // The privacy-filtered render never outlives its export, even across recreation. + source.delete() } }, - ) { result -> - source.delete() - Feedback.show(activity, if (result.isSuccess) R.string.weekly_png_saved else R.string.weekly_png_failed) - } + always = { result -> + Feedback.show( + activity, + result.fold({ R.string.weekly_png_saved }) { + DocumentAccess.exportFailureMessage(it, R.string.weekly_png_failed) + }, + ) + }, + ) } fun saveState(outState: Bundle) { diff --git a/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt b/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt index 6bb9dae..574b3f5 100644 --- a/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt +++ b/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt @@ -16,7 +16,9 @@ import java.util.concurrent.Executors * Results are delivered on the main thread only while the owner is not destroyed, and a keyed * request supersedes earlier requests with the same key, so a stale load can never overwrite a * newer one. Running tasks are allowed to finish after destruction because they may be committing - * data; only their results are dropped. + * data; only their results are dropped. Effects that do not need the screen, such as a toast through + * [Feedback], temp-file cleanup, or refreshing whichever screen is showing, go in `always`, which runs + * once for every finished task even when the screen was recreated meanwhile. */ internal class BackgroundWork(owner: LifecycleOwner, threadName: String) { private val main = Handler(Looper.getMainLooper()) @@ -42,7 +44,8 @@ internal class BackgroundWork(owner: LifecycleOwner, threadName: String) { fun run( key: String? = null, task: () -> T, - onResult: (Result) -> Unit, + always: (Result) -> Unit = {}, + onResult: (Result) -> Unit = {}, ) { if (destroyed) return val generation = key?.let { generations[it] = (generations[it] ?: 0) + 1; generations[it] } @@ -53,9 +56,8 @@ internal class BackgroundWork(owner: LifecycleOwner, threadName: String) { result.exceptionOrNull()?.let { Log.w(TAG, "Background task failed: ${it.javaClass.name}") } main.post { key?.let { running[it] = ((running[it] ?: 1) - 1).coerceAtLeast(0) } - if (destroyed) return@post - if (key != null && generations[key] != generation) return@post - onResult(result) + val current = !destroyed && (key == null || generations[key] == generation) + deliverResult(result, current, always, onResult) } } } @@ -64,3 +66,17 @@ internal class BackgroundWork(owner: LifecycleOwner, threadName: String) { const val TAG = "GF2Work" } } + +/** + * Delivers one finished task: [always] runs exactly once, and [onResult] only while the owning + * screen is alive and the request is still the newest for its key. + */ +internal fun deliverResult( + result: Result, + current: Boolean, + always: (Result) -> Unit, + onResult: (Result) -> Unit, +) { + always(result) + if (current) onResult(result) +} diff --git a/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt b/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt index 9198472..0783012 100644 --- a/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt +++ b/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt @@ -2,7 +2,10 @@ package dev.gf2log.app.ui import android.content.ContentResolver import android.net.Uri +import android.provider.DocumentsContract import android.provider.OpenableColumns +import androidx.annotation.StringRes +import dev.gf2log.app.R import java.io.InputStream import java.io.OutputStream import java.nio.file.Paths @@ -38,6 +41,37 @@ internal object DocumentAccess { return output ?: error("Document provider did not open an output stream") } + /** + * Writes an export into a document the picker has already created. A failed or refused write + * deletes that document (best effort), so a 0-byte or truncated file never sits beside real + * exports looking like one. + */ + fun writeExport(resolver: ContentResolver, destination: Uri, write: (OutputStream) -> Unit) = + writeOrDiscard( + open = { openOutput(resolver, destination) }, + discard = { discard(resolver, destination) }, + write = write, + ) + + /** Deletes a created document that will not receive its export; failures are ignored. */ + fun discard(resolver: ContentResolver, destination: Uri) { + runCatching { DocumentsContract.deleteDocument(resolver, destination) } + } + + internal fun writeOrDiscard(open: () -> OutputStream, discard: () -> Unit, write: (OutputStream) -> Unit) { + try { + open().use(write) + } catch (error: Throwable) { + runCatching(discard).exceptionOrNull()?.let(error::addSuppressed) + throw error + } + } + + /** The failure message for an export: a refused provider names the locations that work. */ + @StringRes + fun exportFailureMessage(error: Throwable?, @StringRes fallback: Int): Int = + if (error is UntrustedDocumentProviderException) R.string.export_location_unsupported else fallback + fun openInput(resolver: ContentResolver, source: Uri): InputStream { requireTrusted(source) return resolver.openInputStream(source) @@ -50,12 +84,14 @@ internal object DocumentAccess { } }.getOrNull() - private fun requireTrusted(uri: Uri) { - if (uri.scheme != ContentResolver.SCHEME_CONTENT) { - throw SecurityException("Unsupported document URI scheme") - } - if (uri.authority !in TRUSTED_AUTHORITIES) { - throw SecurityException("Untrusted document provider") + private fun requireTrusted(uri: Uri) = requireTrusted(uri.scheme, uri.authority) + + internal fun requireTrusted(scheme: String?, authority: String?) { + if (scheme != ContentResolver.SCHEME_CONTENT || authority !in TRUSTED_AUTHORITIES) { + throw UntrustedDocumentProviderException() } } } + +/** A document outside the platform and Google providers; callers say which locations work. */ +internal class UntrustedDocumentProviderException : SecurityException("Untrusted document provider") diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index becb4b6..cf8838c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -229,6 +229,7 @@ 파싱 패킷을 복사했습니다 캡처 준비 중 서클 CSV를 내보낼 수 없습니다 + 이 위치에는 저장할 수 없습니다. 기기 저장공간, 다운로드 또는 Google 드라이브를 선택하세요. 캡처한 서클 CSV가 없습니다 선택 기록을 저장할 수 없습니다 선택한 기록은 이미 보관되어 있습니다 @@ -624,6 +625,7 @@ 대기 모드만 켜기 저장된 서클이 없어 대기 모드가 기록하지 않습니다. 먼저 서클을 한 번 캡처하세요. 자동 기록에는 VPN 동의가 필요합니다. 이번 대기 모드에서는 기록하지 않습니다. + 기록하려면 VPN 권한이 필요합니다. 다시 시도하려면 기록 시작을 누르세요. 기록할 서버 선택 선택한 서버마다 게임 하루가 끝나기 5분 전에 기록한 뒤 휴대전화를 잠급니다. 클라이언트는 한 번에 한 서버에만 접속하므로 클라이언트마다 서버를 하나씩 선택하세요. %1$s · %2$s · %3$s 시작 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9db3ec..ad66e9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -229,6 +229,7 @@ Parsed packet copied Preparing capture Unable to export Platoon CSV + This location is not supported. Choose device storage, Downloads, or Google Drive. No Platoon CSV has been captured yet Unable to save selected history Selected history is already saved @@ -632,6 +633,7 @@ Standby only No Platoon is saved yet, so standby mode will not record. Capture your Platoon once first. Automatic recording needs VPN consent. Standby mode will not record this time. + Capture needs VPN permission. Tap Start capture to try again. Choose servers to record Each chosen server is recorded 5 minutes before its game day ends, then the phone locks. A client logs in to one server at a time, so choose one server per client. %1$s · %2$s · starts %3$s diff --git a/app/src/test/java/dev/gf2log/app/ui/BackgroundWorkDeliveryTest.kt b/app/src/test/java/dev/gf2log/app/ui/BackgroundWorkDeliveryTest.kt new file mode 100644 index 0000000..8a83d71 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/ui/BackgroundWorkDeliveryTest.kt @@ -0,0 +1,31 @@ +package dev.gf2log.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BackgroundWorkDeliveryTest { + @Test + fun `a result for a destroyed or superseded screen still runs its completion effects once`() { + var always = 0 + var onResult = 0 + + deliverResult(Result.success(Unit), current = false, always = { always += 1 }, onResult = { onResult += 1 }) + + assertEquals(1, always) + assertEquals(0, onResult) + } + + @Test + fun `a result for the live screen runs both, completion effects first`() { + val order = mutableListOf() + + deliverResult( + Result.failure(IllegalStateException()), + current = true, + always = { order += "always" }, + onResult = { order += "onResult" }, + ) + + assertEquals(listOf("always", "onResult"), order) + } +} diff --git a/app/src/test/java/dev/gf2log/app/ui/ExportDocumentTest.kt b/app/src/test/java/dev/gf2log/app/ui/ExportDocumentTest.kt new file mode 100644 index 0000000..23ef2e9 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/ui/ExportDocumentTest.kt @@ -0,0 +1,107 @@ +package dev.gf2log.app.ui + +import dev.gf2log.app.R +import dev.gf2log.app.management.BackupArchive +import dev.gf2log.app.vpnConsentDeniedMessage +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.OutputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExportDocumentTest { + @Test + fun `a failed write deletes the created document once and reports the failure`() { + var discards = 0 + var closed = false + val stream = object : OutputStream() { + override fun write(b: Int) = Unit + override fun close() { + closed = true + } + } + val failure = IOException("disk full") + + val thrown = assertThrows(IOException::class.java) { + DocumentAccess.writeOrDiscard(open = { stream }, discard = { discards += 1 }) { throw failure } + } + + assertSame(failure, thrown) + assertEquals(1, discards) + assertTrue(closed) + } + + @Test + fun `a complete write keeps the document`() { + var discards = 0 + val output = ByteArrayOutputStream() + + DocumentAccess.writeOrDiscard(open = { output }, discard = { discards += 1 }) { it.write(byteArrayOf(1, 2)) } + + assertEquals(0, discards) + assertEquals(2, output.size()) + } + + @Test + fun `an untrusted provider is refused before any stream opens and the document is discarded`() { + var opened = false + var discards = 0 + + assertThrows(UntrustedDocumentProviderException::class.java) { + DocumentAccess.writeOrDiscard( + open = { + DocumentAccess.requireTrusted("content", "com.microsoft.skydrive.content.StorageAccessProvider") + opened = true + ByteArrayOutputStream() + }, + discard = { discards += 1 }, + ) {} + } + + assertTrue(!opened) + assertEquals(1, discards) + assertThrows(UntrustedDocumentProviderException::class.java) { + DocumentAccess.requireTrusted("file", "com.android.externalstorage.documents") + } + DocumentAccess.requireTrusted("content", "com.android.externalstorage.documents") + } + + @Test + fun `only a refused provider replaces the export's own failure message`() { + assertEquals( + R.string.export_location_unsupported, + DocumentAccess.exportFailureMessage(UntrustedDocumentProviderException(), R.string.status_export_failed), + ) + assertEquals( + R.string.status_export_failed, + DocumentAccess.exportFailureMessage(SecurityException("Export URI has no path"), R.string.status_export_failed), + ) + assertEquals( + R.string.weekly_png_failed, + DocumentAccess.exportFailureMessage(IOException(), R.string.weekly_png_failed), + ) + } + + @Test + fun `a backup refused before writing leaves an empty document, which is why it is deleted`() { + val output = ByteArrayOutputStream() + + assertThrows(IllegalArgumentException::class.java) { + BackupArchive.write(output, File("does-not-exist.db"), null) + } + + assertEquals(0, output.size()) + } + + @Test + fun `a refused VPN consent on Home is always explained`() { + assertNull(vpnConsentDeniedMessage(android.app.Activity.RESULT_OK)) + assertEquals(R.string.capture_vpn_needed, vpnConsentDeniedMessage(android.app.Activity.RESULT_CANCELED)) + assertEquals(R.string.capture_vpn_needed, vpnConsentDeniedMessage(42)) + } +} From 75384568434225ff6cedc22129c87550c29260ce Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:05:29 +0900 Subject: [PATCH 24/98] Bound the decoded elements of every captured payload Decoding charges each submessage and repeated scalar to a per-payload budget, and the parser applies the same budget to a merged continuation sequence, discarding and quarantining it like the byte limit. Pending continuations are concatenated once on emission instead of on every frame. Pre-identity and admission buffers also weigh payloads by their decoded elements, and payloads whose history is off are no longer held before identification. Findings: sec-capture-1, sec-capture-m2, sec-protocol-1 --- .../app/capture/BoundedFlowPayloadBuffer.kt | 32 ++++++-- .../gf2log/app/capture/CaptureFlowEngine.kt | 8 +- .../capture/PendingPlatoonAdmissionStore.kt | 12 ++- .../capture/BoundedFlowPayloadBufferTest.kt | 41 +++++++++++ .../PendingPlatoonAdmissionStoreTest.kt | 38 ++++++++++ docs/ARCHITECTURE.md | 14 +++- .../dev/gf2log/protocol/Gfl2PayloadDecoder.kt | 41 ++++++++--- .../dev/gf2log/protocol/Gfl2StreamParser.kt | 73 ++++++++++++------- .../gf2log/protocol/internal/ProtoReader.kt | 39 +++++++++- .../dev/gf2log/protocol/model/GameData.kt | 24 ++++++ .../gf2log/protocol/Gfl2StreamParserTest.kt | 72 ++++++++++++++++++ 11 files changed, 341 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt index 8658dcc..5df3b8e 100644 --- a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt +++ b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt @@ -3,37 +3,50 @@ package dev.gf2log.app.capture /** * Bounded pre-identity quarantine; overflow permanently rejects that flow until closure. * Capture confines it to the worker thread; access stays serialized so sharing it remains safe. + * Besides counting items, it sums each item's [weigh] against [maxTotalWeight], because one + * decoded payload can hold far more elements than another. */ internal class BoundedFlowPayloadBuffer( private val maxItemsPerFlow: Int, private val maxTotalItems: Int, + private val maxTotalWeight: Long = Long.MAX_VALUE, + private val weigh: (T) -> Int = { 1 }, ) { private val pending = mutableMapOf>() + private val pendingWeight = mutableMapOf() private val rejected = mutableSetOf() private var totalItems = 0 + private var totalWeight = 0L init { require(maxItemsPerFlow > 0) require(maxTotalItems > 0) + require(maxTotalWeight > 0) } @Synchronized fun offer(flowId: Long, item: T): OfferResult { if (flowId in rejected) return OfferResult.REJECTED val items = pending.getOrPut(flowId, ::ArrayDeque) - if (items.size >= maxItemsPerFlow || totalItems >= maxTotalItems) { + val weight = weigh(item).toLong().coerceAtLeast(1) + if ( + items.size >= maxItemsPerFlow || + totalItems >= maxTotalItems || + weight > maxTotalWeight - totalWeight + ) { reject(flowId) return OfferResult.OVERFLOW } items.addLast(item) totalItems += 1 + totalWeight += weight + pendingWeight[flowId] = (pendingWeight[flowId] ?: 0L) + weight return OfferResult.ACCEPTED } @Synchronized fun take(flowId: Long): List { - val items = pending.remove(flowId) ?: return emptyList() - totalItems -= items.size + val items = discardPending(flowId) ?: return emptyList() return items.toList() } @@ -42,21 +55,30 @@ internal class BoundedFlowPayloadBuffer( @Synchronized fun reject(flowId: Long) { - totalItems -= pending.remove(flowId)?.size ?: 0 + discardPending(flowId) rejected += flowId } @Synchronized fun remove(flowId: Long) { - totalItems -= pending.remove(flowId)?.size ?: 0 + discardPending(flowId) rejected.remove(flowId) } @Synchronized fun clear() { pending.clear() + pendingWeight.clear() rejected.clear() totalItems = 0 + totalWeight = 0 + } + + private fun discardPending(flowId: Long): ArrayDeque? { + totalWeight -= pendingWeight.remove(flowId) ?: 0L + val items = pending.remove(flowId) ?: return null + totalItems -= items.size + return items } enum class OfferResult { ACCEPTED, OVERFLOW, REJECTED } diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt index 70bf453..75356d9 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt @@ -14,6 +14,7 @@ import dev.gf2log.protocol.Gfl2StreamParser import dev.gf2log.protocol.model.ParseEvent import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonProfileData +import dev.gf2log.protocol.model.decodedElementCount import java.io.File import java.util.concurrent.ConcurrentHashMap @@ -38,7 +39,8 @@ internal class CaptureFlowEngine( private val preIdentity = BoundedFlowPayloadBuffer( MAX_PRE_IDENTITY_PAYLOADS_PER_FLOW, MAX_PRE_IDENTITY_PAYLOADS_TOTAL, - ) + MAX_PRE_IDENTITY_ELEMENTS_TOTAL, + ) { it.data.decodedElementCount() } private val historyStores = HashMap() private val observedProfiles = HashMap>() private var ownerResolver = CaptureFlowOwnerResolver(context) @@ -293,6 +295,9 @@ internal class CaptureFlowEngine( } private fun holdUntilIdentified(flowId: Long, payload: ParsedPayload) { + // Only history consumes the optional types after identification, and the Platoon types' + // history is always on, so a payload whose history is off would be retained for nothing. + if (!payloadHistory.isEnabled(payload.payloadType)) return if (admission.retain(flowId, payload)) return if (preIdentity.offer(flowId, payload) == BoundedFlowPayloadBuffer.OfferResult.OVERFLOW) { CaptureStatus.report(CaptureNotice.FLOW_BUFFER_OVERFLOW) @@ -318,5 +323,6 @@ internal class CaptureFlowEngine( private companion object { const val MAX_PRE_IDENTITY_PAYLOADS_PER_FLOW = 32 const val MAX_PRE_IDENTITY_PAYLOADS_TOTAL = 128 + const val MAX_PRE_IDENTITY_ELEMENTS_TOTAL = 65_536L } } 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 a36d04f..230e625 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt @@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting import dev.gf2log.app.SupportedGamePackages import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonProfileData +import dev.gf2log.protocol.model.decodedElementCount import java.time.Instant import java.util.UUID import java.util.concurrent.CopyOnWriteArraySet @@ -71,6 +72,7 @@ internal object PendingPlatoonAdmissionStore { private val candidates = linkedMapOf() private val tokenByIdentity = mutableMapOf() private var totalPayloads = 0 + private var totalElements = 0L private val listeners = CopyOnWriteArraySet<(List) -> Unit>() /** @@ -130,15 +132,18 @@ internal object PendingPlatoonAdmissionStore { fun offer(token: String, flowId: Long, payload: ParsedPayload): OfferResult { val candidate = candidates[token] ?: return OfferResult.Missing if (candidate.claimed) return OfferResult.Claimed + val elements = payload.data.decodedElementCount() if ( candidate.payloads.size >= MAX_PAYLOADS_PER_CANDIDATE || - totalPayloads >= MAX_TOTAL_PAYLOADS + totalPayloads >= MAX_TOTAL_PAYLOADS || + totalElements + elements > MAX_TOTAL_ELEMENTS ) { return OfferResult.Overflow(removeLocked(token)?.flowIds.orEmpty()) } candidate.flowIds += flowId candidate.payloads += BufferedPayload(flowId, payload) totalPayloads += 1 + totalElements += elements return OfferResult.Accepted } @@ -217,6 +222,7 @@ internal object PendingPlatoonAdmissionStore { candidates.clear() tokenByIdentity.clear() totalPayloads = 0 + totalElements = 0 listeners.clear() } @@ -235,6 +241,7 @@ internal object PendingPlatoonAdmissionStore { val removed = candidates.remove(token) ?: return null tokenByIdentity.remove(IdentityKey(removed.ownerPackage, removed.profile.platoonId), token) totalPayloads -= removed.payloads.size + totalElements -= removed.payloads.sumOf { it.payload.data.decodedElementCount().toLong() } notifyChanged() return removed } @@ -251,4 +258,7 @@ internal object PendingPlatoonAdmissionStore { internal const val MAX_CANDIDATES = 4 internal const val MAX_PAYLOADS_PER_CANDIDATE = 64 internal const val MAX_TOTAL_PAYLOADS = 128 + + /** Decoded elements every candidate together may hold; a payload count alone bounds no memory. */ + internal const val MAX_TOTAL_ELEMENTS = 65_536L } diff --git a/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt b/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt index f0603a9..756bf29 100644 --- a/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt @@ -1,5 +1,10 @@ package dev.gf2log.app.capture +import dev.gf2log.protocol.Gfl2PayloadDecoder +import dev.gf2log.protocol.model.ParsedPayload +import dev.gf2log.protocol.model.Weapon +import dev.gf2log.protocol.model.WeaponsData +import dev.gf2log.protocol.model.decodedElementCount import java.lang.reflect.Modifier import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -49,6 +54,35 @@ class BoundedFlowPayloadBufferTest { assertEquals(listOf("three"), buffer.take(3)) } + @Test + fun aggregateWeightOverflowRejectsTheFlowAndRemovalReleasesItsWeight() { + val buffer = BoundedFlowPayloadBuffer(8, 8, maxTotalWeight = 10) { it.length } + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(1, "aaaa")) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(2, "bbbb")) + + assertEquals(BoundedFlowPayloadBuffer.OfferResult.OVERFLOW, buffer.offer(1, "ccc")) + assertTrue(buffer.isRejected(1)) + assertTrue(buffer.take(1).isEmpty()) + + // Flow 1's four units were released when it was rejected; flow 2 still holds four. + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(3, "dddddd")) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.OVERFLOW, buffer.offer(4, "e")) + assertEquals(listOf("bbbb"), buffer.take(2)) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(5, "ffff")) + } + + @Test + fun decodedPayloadsAreWeighedByTheirElements() { + val buffer = BoundedFlowPayloadBuffer(8, 8, maxTotalWeight = 100) { + it.data.decodedElementCount() + } + val small = weapons(40) + + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(1, small)) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(1, small)) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.OVERFLOW, buffer.offer(1, weapons(21))) + } + @Test fun removalAndClearReleaseAggregateCapacity() { val buffer = BoundedFlowPayloadBuffer(2, 2) @@ -76,4 +110,11 @@ class BoundedFlowPayloadBufferTest { )) } } + + private fun weapons(count: Int) = ParsedPayload( + messageId = 1, + payloadType = Gfl2PayloadDecoder.TYPE_WEAPONS, + isEndOfMessage = true, + data = WeaponsData(List(count) { Weapon(it.toUInt(), 0u, 0u, 0u, 0u, 0u, 0u, emptyList()) }), + ) } 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 9b65cd9..b0e9740 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt @@ -1,8 +1,11 @@ package dev.gf2log.app.capture import dev.gf2log.app.SupportedGamePackages +import dev.gf2log.protocol.Gfl2PayloadDecoder import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonProfileData +import dev.gf2log.protocol.model.Weapon +import dev.gf2log.protocol.model.WeaponsData import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -202,6 +205,34 @@ class PendingPlatoonAdmissionStoreTest { assertTrue(PendingPlatoonAdmissionStore.summaries().none { it.token == token }) } + @Test + fun decodedElementBudgetIsSharedAcrossCandidatesAndReleasedOnRemoval() { + val tokens = (1..3).map { index -> + requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(index.toUInt(), "P$index"), + flowId = index.toLong(), + ).token, + ) + } + val large = (PendingPlatoonAdmissionStore.MAX_TOTAL_ELEMENTS * 3 / 5).toInt() + + assertEquals( + PendingPlatoonAdmissionStore.OfferResult.Accepted, + PendingPlatoonAdmissionStore.offer(tokens[0], 1L, weapons(large)), + ) + assertEquals( + PendingPlatoonAdmissionStore.OfferResult.Overflow(setOf(2L)), + PendingPlatoonAdmissionStore.offer(tokens[1], 2L, weapons(large)), + ) + PendingPlatoonAdmissionStore.discard(tokens[0]) + assertEquals( + PendingPlatoonAdmissionStore.OfferResult.Accepted, + PendingPlatoonAdmissionStore.offer(tokens[2], 3L, weapons(large)), + ) + } + @Test fun candidateCountRejectsOnlyTheNewFlow() { repeat(PendingPlatoonAdmissionStore.MAX_CANDIDATES) { index -> @@ -235,4 +266,11 @@ class PendingPlatoonAdmissionStoreTest { isEndOfMessage = true, data = profile, ) + + private fun weapons(count: Int) = ParsedPayload( + messageId = 1, + payloadType = Gfl2PayloadDecoder.TYPE_WEAPONS, + isEndOfMessage = true, + data = WeaponsData(List(count) { Weapon(it.toUInt(), 0u, 0u, 0u, 0u, 0u, 0u, emptyList()) }), + ) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae40198..8937807 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -115,6 +115,10 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. A malformed or truncated continuation frame discards its dataset the same way. Quarantine is advanced from frame metadata before protobuf decoding, so even a malformed terminal fragment clears the discarded dataset deterministically. + Decoding also charges every submessage and repeated scalar against a budget of + 32,768 elements per logical payload, across its continuations, and exceeding + it is handled like the byte limit, so a frame of minimal elements cannot + amplify into an unbounded object graph. - One serial `CaptureWorker` owns every parser, flow, session, and admission structure, so that state needs no locks. Native callbacks share a budget of 256 queued chunks; lifecycle, confirm, and discard tasks bypass that budget @@ -129,11 +133,13 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. 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. + objects, all flows together at most 65,536 decoded elements, and only payload + types whose history is on. Overflow rejects that flow until closure. The + profile registry holds at most 16 bounded identities. - Unconfirmed profile admission is capped at 4 candidates, 64 decoded payloads - per candidate, and 128 payloads total. Overflow rejects every flow belonging - to that candidate rather than partially persisting it. + per candidate, 128 payloads total, and 65,536 decoded elements 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. Replay binds every claimed flow before routing any buffered payload, so a replay that fails has diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt index 8260c70..3ec7c22 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt @@ -1,5 +1,6 @@ package dev.gf2log.protocol +import dev.gf2log.protocol.internal.DecodeBudget import dev.gf2log.protocol.internal.ProtoReader import dev.gf2log.protocol.internal.collectMessages import dev.gf2log.protocol.internal.forEachField @@ -59,19 +60,39 @@ object Gfl2PayloadDecoder { else -> false } + /** + * Upper bound on the submessages and repeated scalars one logical payload may decode. A + * two-byte empty submessage would otherwise turn a 2 MiB continuation sequence into about a + * million objects; real rosters, inventories, and formations stay far below this. + */ + const val MAX_DECODED_ELEMENTS = 32_768 + + @Throws(ProtocolException::class) + fun decode(type: Int, bytes: ByteArray): GameData? = + decodeCounted(type, bytes, MAX_DECODED_ELEMENTS)?.data + + /** Decodes [bytes] while charging every element to a budget of [maxElements]. */ @Throws(ProtocolException::class) - fun decode(type: Int, bytes: ByteArray): GameData? = when (type) { - TYPE_WEAPONS -> decodeWeapons(ProtoReader(bytes)) - TYPE_WEAPON_MODS -> decodeWeaponMods(ProtoReader(bytes)) - TYPE_PUBLIC_SKILL_ITEMS -> decodePublicSkillItems(ProtoReader(bytes)) - TYPE_PLATOON_PROFILE -> decodePlatoonProfile(ProtoReader(bytes)) - TYPE_GUILD_MEMBERS -> decodeGuildMembers(ProtoReader(bytes)) - TYPE_PLATOON_ACTIVITY -> decodePlatoonActivity(ProtoReader(bytes)) - TYPE_PLATOON_UPDATES -> decodePlatoonUpdates(ProtoReader(bytes)) - TYPE_FORMATIONS -> decodeFormationsResponse(ProtoReader(bytes)) - else -> null + internal fun decodeCounted(type: Int, bytes: ByteArray, maxElements: Int): Decoded? { + val budget = DecodeBudget(maxElements) + val reader = ProtoReader(bytes, budget) + val data = when (type) { + TYPE_WEAPONS -> decodeWeapons(reader) + TYPE_WEAPON_MODS -> decodeWeaponMods(reader) + TYPE_PUBLIC_SKILL_ITEMS -> decodePublicSkillItems(reader) + TYPE_PLATOON_PROFILE -> decodePlatoonProfile(reader) + TYPE_GUILD_MEMBERS -> decodeGuildMembers(reader) + TYPE_PLATOON_ACTIVITY -> decodePlatoonActivity(reader) + TYPE_PLATOON_UPDATES -> decodePlatoonUpdates(reader) + TYPE_FORMATIONS -> decodeFormationsResponse(reader) + else -> return null + } + return Decoded(data, budget.consumed) } + /** One decoded payload and the elements its decoding charged to the budget. */ + internal class Decoded(val data: GameData, val elements: Int) + private fun decodeWeapons(reader: ProtoReader) = WeaponsData(reader.collectMessages(1, ::decodeWeapon)) diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt index 4a76063..f5d0e8b 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt @@ -7,6 +7,7 @@ import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.ParseEvent import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonActivityData +import dev.gf2log.protocol.model.PlatoonProfileData import dev.gf2log.protocol.model.PlatoonUpdatesData import dev.gf2log.protocol.model.PublicSkillItemsData import dev.gf2log.protocol.model.WeaponModsData @@ -16,10 +17,12 @@ class Gfl2StreamParser( maximumBufferedBytes: Int = DEFAULT_MAXIMUM_BUFFERED_BYTES, private val maximumPendingPayloadBytes: Int = DEFAULT_MAXIMUM_PENDING_PAYLOAD_BYTES, private val maximumPendingContinuations: Int = DEFAULT_MAXIMUM_PENDING_CONTINUATIONS, + private val maximumPayloadElements: Int = Gfl2PayloadDecoder.MAX_DECODED_ELEMENTS, ) { init { require(maximumPendingPayloadBytes > 0) { "Pending payload byte limit must be positive" } require(maximumPendingContinuations > 0) { "Pending continuation limit must be positive" } + require(maximumPayloadElements > 0) { "Payload element limit must be positive" } } private val buffer = ByteAccumulator(maximumBufferedBytes) @@ -104,7 +107,9 @@ class Gfl2StreamParser( } else if (!consumeQuarantinedPayload(type, messageId, isEndOfMessage)) { val payload = bytes.copyOfRange(offset + PAYLOAD_HEADER_SIZE, offset + payloadSize) try { - val decoded = requireNotNull(Gfl2PayloadDecoder.decode(type, payload)) + val decoded = requireNotNull( + Gfl2PayloadDecoder.decodeCounted(type, payload, maximumPayloadElements), + ) acceptDecodedPayload(messageId, type, isEndOfMessage, payload.size, decoded, events) } catch (error: ProtocolException) { events += ParseEvent.Warning( @@ -123,19 +128,21 @@ class Gfl2StreamParser( type: Int, isEndOfMessage: Boolean, payloadBytes: Int, - data: GameData, + decoded: Gfl2PayloadDecoder.Decoded, events: MutableList, ) { + val data = decoded.data val pending = pendingPayload if (pending != null) { val continued = pending.type == type && continues(pending.previousMessageId, messageId) - val merged = if (continued) merge(pending.data, data) else null - if (continued && merged != null) { + if (continued && isMergeable(pending.parts.first(), data)) { val accumulatedBytes = pending.accumulatedPayloadBytes + payloadBytes.toLong() val continuationCount = pending.continuationCount + 1 + val accumulatedElements = pending.accumulatedElements + decoded.elements if ( accumulatedBytes > maximumPendingPayloadBytes || - continuationCount > maximumPendingContinuations + continuationCount > maximumPendingContinuations || + accumulatedElements > maximumPayloadElements ) { pendingPayload = null quarantinedPayload = if (isTerminal(messageId, isEndOfMessage)) { @@ -148,11 +155,12 @@ class Gfl2StreamParser( ) return } - pending.data = merged + pending.parts += data pending.previousMessageId = messageId pending.isEndOfMessage = isEndOfMessage pending.accumulatedPayloadBytes = accumulatedBytes pending.continuationCount = continuationCount + pending.accumulatedElements = accumulatedElements if (isTerminal(messageId, isEndOfMessage)) emitPending(events) return } @@ -175,9 +183,10 @@ class Gfl2StreamParser( type = type, previousMessageId = messageId, isEndOfMessage = isEndOfMessage, - data = data, + parts = mutableListOf(data), accumulatedPayloadBytes = payloadBytes.toLong(), continuationCount = 1, + accumulatedElements = decoded.elements, ) } } @@ -234,31 +243,38 @@ class Gfl2StreamParser( messageId = pending.previousMessageId, payloadType = pending.type, isEndOfMessage = pending.isEndOfMessage, - data = pending.data, + data = concatenate(pending.parts), ), ) pendingPayload = null } - /** Returns null for payloads that are not lists and therefore cannot be concatenated. */ - private fun merge(first: GameData, second: GameData): GameData? = when { - first is WeaponsData && second is WeaponsData -> WeaponsData(first.weapons + second.weapons) - first is WeaponModsData && second is WeaponModsData -> - WeaponModsData(first.mods + second.mods) - first is PublicSkillItemsData && second is PublicSkillItemsData -> - PublicSkillItemsData(first.items + second.items) - first is GuildMembersData && second is GuildMembersData -> - GuildMembersData(first.members + second.members) - first is PlatoonActivityData && second is PlatoonActivityData -> - PlatoonActivityData( - summaries = first.summaries + second.summaries, - entries = first.entries + second.entries, + /** False for payloads that are not lists and therefore cannot be concatenated. */ + private fun isMergeable(first: GameData, second: GameData): Boolean = + first !is PlatoonProfileData && first.javaClass == second.javaClass + + /** + * Concatenates a dataset's frames once, when it is emitted, so a long continuation sequence + * copies each element a single time instead of once per continuation. + */ + private fun concatenate(parts: List): GameData { + val first = parts.first() + if (parts.size == 1) return first + return when (first) { + is WeaponsData -> WeaponsData(parts.flatMap { (it as WeaponsData).weapons }) + is WeaponModsData -> WeaponModsData(parts.flatMap { (it as WeaponModsData).mods }) + is PublicSkillItemsData -> + PublicSkillItemsData(parts.flatMap { (it as PublicSkillItemsData).items }) + is GuildMembersData -> GuildMembersData(parts.flatMap { (it as GuildMembersData).members }) + is PlatoonActivityData -> PlatoonActivityData( + summaries = parts.flatMap { (it as PlatoonActivityData).summaries }, + entries = parts.flatMap { (it as PlatoonActivityData).entries }, ) - first is PlatoonUpdatesData && second is PlatoonUpdatesData -> - PlatoonUpdatesData(first.entries + second.entries) - first is FormationsData && second is FormationsData -> - FormationsData(first.formations + second.formations) - else -> null + is PlatoonUpdatesData -> + PlatoonUpdatesData(parts.flatMap { (it as PlatoonUpdatesData).entries }) + is FormationsData -> FormationsData(parts.flatMap { (it as FormationsData).formations }) + is PlatoonProfileData -> error("A Platoon profile is never merged") + } } /** The reference continuation rule: after message id 0, or within the same message id. */ @@ -281,13 +297,14 @@ class Gfl2StreamParser( const val DEFAULT_MAXIMUM_PENDING_CONTINUATIONS = 64 } - private data class PendingPayload( + private class PendingPayload( val type: Int, var previousMessageId: Int, var isEndOfMessage: Boolean, - var data: GameData, + val parts: MutableList, var accumulatedPayloadBytes: Long, var continuationCount: Int, + var accumulatedElements: Int, ) private data class QuarantinedPayload( diff --git a/protocol/src/main/java/dev/gf2log/protocol/internal/ProtoReader.kt b/protocol/src/main/java/dev/gf2log/protocol/internal/ProtoReader.kt index f26fd43..ec16fc2 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/internal/ProtoReader.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/internal/ProtoReader.kt @@ -7,8 +7,28 @@ internal data class ProtoField( val wireType: Int, ) +/** + * Counts the submessages and repeated scalars one payload decodes, so a crafted payload of + * minimal elements cannot amplify a bounded frame into an unbounded object graph. Every reader + * nested inside one payload shares the same budget. + */ +internal class DecodeBudget(private val limit: Int) { + var consumed = 0 + private set + + init { + require(limit > 0) { "Decode element limit must be positive" } + } + + fun consume() { + if (consumed >= limit) throw ProtocolException("Payload exceeds $limit decoded elements") + consumed += 1 + } +} + internal class ProtoReader( private val bytes: ByteArray, + private val budget: DecodeBudget, ) { private var position = 0 @@ -33,14 +53,25 @@ internal class ProtoReader( fun readString(field: ProtoField): String = readBytes(field).toString(Charsets.UTF_8) - fun readMessage(field: ProtoField): ProtoReader = ProtoReader(readBytes(field)) + /** Each submessage is one decoded element, charged before its bytes are copied. */ + fun readMessage(field: ProtoField): ProtoReader { + requireWireType(field, 2) + budget.consume() + return ProtoReader(readLengthDelimited(), budget) + } fun readRepeatedUInt(field: ProtoField): List = when (field.wireType) { - 0 -> listOf(readVarint()) + 0 -> { + budget.consume() + listOf(readVarint()) + } 2 -> { - val packed = ProtoReader(readLengthDelimited()) + val packed = ProtoReader(readLengthDelimited(), budget) buildList { - while (!packed.exhausted) add(packed.readVarint()) + while (!packed.exhausted) { + budget.consume() + add(packed.readVarint()) + } } } else -> throw ProtocolException( diff --git a/protocol/src/main/java/dev/gf2log/protocol/model/GameData.kt b/protocol/src/main/java/dev/gf2log/protocol/model/GameData.kt index 53a2930..0aec1f0 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/model/GameData.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/model/GameData.kt @@ -2,6 +2,30 @@ package dev.gf2log.protocol.model sealed interface GameData +/** + * The number of list elements, nested ones included, that this payload retains; never below one. + * Buffers that hold decoded payloads weigh them by it, because a payload count alone says nothing + * about how much memory a crafted payload pins. + */ +fun GameData.decodedElementCount(): Int { + val count = when (this) { + is WeaponsData -> weapons.size + weapons.sumOf { it.weaponMods.size } + is WeaponModsData -> mods.size + is PublicSkillItemsData -> items.size + is GuildMembersData -> members.size + is PlatoonProfileData -> 1 + is PlatoonActivityData -> summaries.size + entries.size + is PlatoonUpdatesData -> entries.size + entries.sumOf { it.members.size } + is FormationsData -> formations.size + formations.sumOf { formation -> + formation.dolls.size + formation.dolls.sumOf { doll -> + doll.attachmentUids.size + doll.fixedKeyIds.size + + doll.expansionKeyIds.size + doll.commonKeyUids.size + } + } + } + return count.coerceAtLeast(1) +} + data class WeaponsData(val weapons: List) : GameData data class Weapon( diff --git a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt index 1a4b947..20d7419 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt @@ -9,6 +9,7 @@ import dev.gf2log.protocol.model.PlatoonUpdatesData import dev.gf2log.protocol.model.PublicSkillItemsData import dev.gf2log.protocol.model.WeaponsData import dev.gf2log.protocol.model.WeaponModsData +import dev.gf2log.protocol.model.decodedElementCount import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -481,6 +482,77 @@ class Gfl2StreamParserTest { ) } + @Test + fun oneFrameOfPackedElementsBeyondTheDefaultBudgetIsDiscarded() { + val parser = Gfl2StreamParser() + val zeros = ByteArray(Gfl2PayloadDecoder.MAX_DECODED_ELEMENTS + 1) + val doll = messageField(3, zeros) + val formations = messageField(1, messageField(1, messageField(2, doll))) + + val events = parser.accept( + outerMessage(42, payload(Gfl2PayloadDecoder.TYPE_FORMATIONS, formations)), + ) + + assertEquals(1, events.filterIsInstance().size) + assertTrue(events.filterIsInstance().isEmpty()) + assertRecovers(parser) + } + + @Test + fun oneFrameOfEmptySubmessagesBeyondTheBudgetIsDiscarded() { + val parser = Gfl2StreamParser(maximumPayloadElements = 100) + val emptyWeapons = hex("0a00".repeat(101)) + + val events = parser.accept( + outerMessage(42, payload(Gfl2PayloadDecoder.TYPE_WEAPONS, emptyWeapons)), + ) + + assertEquals(1, events.filterIsInstance().size) + assertTrue(events.filterIsInstance().isEmpty()) + assertRecovers(parser) + } + + @Test + fun continuationsBeyondTheMergedElementBudgetAreQuarantined() { + val parser = Gfl2StreamParser(maximumPayloadElements = 10) + val continuation = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_WEAPONS, hex("0a00".repeat(4))), + ) + + assertTrue(parser.accept(continuation).isEmpty()) + assertTrue(parser.accept(continuation).isEmpty()) + val overflow = parser.accept(continuation) + + assertEquals(1, overflow.filterIsInstance().size) + assertTrue(overflow.filterIsInstance().isEmpty()) + assertTrue(parser.accept(continuation).isEmpty()) + assertTrue( + parser.accept( + outerMessage(42, payload(Gfl2PayloadDecoder.TYPE_WEAPONS, hex("0a00"))), + ).isEmpty(), + ) + assertRecovers(parser) + } + + @Test + fun continuationsWithinTheElementBudgetMergeInOrder() { + val parser = Gfl2StreamParser(maximumPayloadElements = 12) + val continuation = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_WEAPONS, hex("0a00".repeat(4))), + ) + + assertTrue(parser.accept(continuation).isEmpty()) + assertTrue(parser.accept(continuation).isEmpty()) + val merged = parser.accept( + outerMessage(42, payload(Gfl2PayloadDecoder.TYPE_WEAPONS, hex("0a00".repeat(4)))), + ).singlePayload().value.data as WeaponsData + + assertEquals(12, merged.weapons.size) + assertEquals(12, merged.decodedElementCount()) + } + private fun weaponsPayload(): ByteArray { val weaponMod = uintField(1, 91uL) + uintField(2, 17uL) val weapon = uintField(1, 42uL) + From 7a22a37f232b09f802ea26a23fd52ebf5c911b2c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:05:35 +0900 Subject: [PATCH 25/98] Truncate Platoon text on a code point boundary A name, announcement or declaration longer than its limit no longer ends in an unpaired high surrogate when the cut lands inside a pair. Findings: sec-protocol-m2 --- .../dev/gf2log/protocol/Gfl2PayloadDecoder.kt | 16 +++++++++++++--- .../dev/gf2log/protocol/Gfl2StreamParserTest.kt | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt index 3ec7c22..cfa6517 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt @@ -221,15 +221,15 @@ object Gfl2PayloadDecoder { reader.forEachField { field -> when (field.number) { 1 -> platoonId = reader.readUInt(field).toUInt() - 2 -> platoonName = reader.readString(field).take(MAX_PLATOON_NAME_CHARS) + 2 -> platoonName = reader.readString(field).takeCodePoints(MAX_PLATOON_NAME_CHARS) 3 -> level = reader.readUInt(field).toUInt() 4 -> exp = reader.readUInt(field).toUInt() - 6 -> announcement = reader.readString(field).take(MAX_PLATOON_TEXT_CHARS) + 6 -> announcement = reader.readString(field).takeCodePoints(MAX_PLATOON_TEXT_CHARS) // CS_ChangeGuildFlag and asymmetric live profiles establish that field 9 is // the mark/design selector while field 10 is the colored frame selector. 9 -> bannerMarkId = reader.readUInt(field).toUInt() 10 -> bannerFrameId = reader.readUInt(field).toUInt() - 12 -> declaration = reader.readString(field).take(MAX_PLATOON_TEXT_CHARS) + 12 -> declaration = reader.readString(field).takeCodePoints(MAX_PLATOON_TEXT_CHARS) 13 -> joinPolicyFlag = reader.readBoolean(field) else -> reader.skip(field) } @@ -417,6 +417,16 @@ object Gfl2PayloadDecoder { return Doll(dollId, weaponUid, attachmentUids, fixedKeyIds, expansionKeyIds, commonKeyUids) } + /** + * Truncates to at most [maxUnits] UTF-16 units without splitting a surrogate pair, so a + * stored name never ends in an unpaired surrogate that no keyboard can type back. + */ + internal fun String.takeCodePoints(maxUnits: Int): String { + if (length <= maxUnits) return this + val splitsPair = maxUnits > 0 && Character.isHighSurrogate(this[maxUnits - 1]) + return substring(0, if (splitsPair) maxUnits - 1 else maxUnits) + } + private const val MAX_PLATOON_NAME_CHARS = 128 private const val MAX_PLATOON_TEXT_CHARS = 4_096 } diff --git a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt index 20d7419..f46f34a 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt @@ -553,6 +553,22 @@ class Gfl2StreamParserTest { assertEquals(12, merged.decodedElementCount()) } + @Test + fun platoonTextIsTruncatedOnACodePointBoundary() { + val emoji = "😀" + val split = Gfl2PayloadDecoder.decode( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, + platoonProfilePayload(1uL, "a".repeat(127) + emoji), + ) as PlatoonProfileData + val exact = Gfl2PayloadDecoder.decode( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, + platoonProfilePayload(1uL, "a".repeat(126) + emoji + "b"), + ) as PlatoonProfileData + + assertEquals("a".repeat(127), split.platoonName) + assertEquals("a".repeat(126) + emoji, exact.platoonName) + } + private fun weaponsPayload(): ByteArray { val weaponMod = uintField(1, 91uL) + uintField(2, 17uL) val weapon = uintField(1, 42uL) + From 7017e956c97a3b7ab3dbff280c96142e1adbcb6f Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:06:31 +0900 Subject: [PATCH 26/98] Validate manual weekly edits against the day's range and evidence Manual merit is capped at a Standard day's 90 or a generous Gunsmoke bound and score at one billion, so no weekly sum can overflow. A Login or Patrol 'no' that a fact or positive score disproves is refused in the editor, and a stored one no longer constrains the Gunsmoke solver. Findings: q-weekly-engine-3, q-weekly-engine-4 --- .../main/java/dev/gf2log/app/WeeklyDialogs.kt | 22 +++++-- .../app/management/GunsmokeWeekSolver.kt | 24 ++++++-- .../app/management/WeeklyOverrideEditing.kt | 44 ++++++++++++-- .../app/management/WeeklyReportBuilder.kt | 14 +++-- app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../app/management/GunsmokeWeekSolverTest.kt | 56 ++++++++++++++++++ .../management/WeeklyOverrideEditingTest.kt | 59 +++++++++++++++++++ 8 files changed, 203 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt index 89d7e96..44a93c5 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -138,13 +138,25 @@ internal object WeeklyDialogs { listOf(edit), ) if (probe is WeeklyOverrideEditing.Result.Invalid) { - error.showMessage(activity.getString( + error.showMessage( when (probe.field) { - WeeklyOverrideEditing.Field.ATTEMPTS -> R.string.invalid_attempts - WeeklyOverrideEditing.Field.ACTIVITY -> R.string.invalid_activity_choice - else -> R.string.invalid_counter + WeeklyOverrideEditing.Field.ATTEMPTS -> activity.getString(R.string.invalid_attempts) + WeeklyOverrideEditing.Field.ACTIVITY -> activity.getString(R.string.invalid_activity_choice) + WeeklyOverrideEditing.Field.MERIT -> if (isGunsmokeWeek) { + activity.getString(R.string.invalid_counter) + } else { + activity.getString( + R.string.invalid_standard_merit, + WeeklyOverrideEditing.maximumDailyMerit(isGunsmokeWeek = false), + ) + } + else -> activity.getString(R.string.invalid_counter) }, - )) + ) + return + } + if (WeeklyOverrideEditing.contradictsEvidence(cell, edit)) { + error.showMessage(activity.getString(R.string.invalid_activity_evidence)) return } dialog.dismiss() 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 6cfc955..85ff5aa 100644 --- a/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt @@ -1415,11 +1415,26 @@ internal object GunsmokeWeekSolver { if (facts.isNotEmpty() && !option.patrol) return false if (cell.hasLoginFact && !option.attended) return false val override = cell.manualOverride ?: return true + val (attended, patrol) = honouredChoices(cell, patrolProven = facts.isNotEmpty()) 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) + (attended == null || attended == option.attended) && + (patrol == null || patrol == option.patrol) + } + + /** + * The manual Login and Daily Patrol choices of [cell] the solver holds paths to. As on the + * displayed cell, a "no" that a fact or counter evidence disproves yields to it, so a stored + * contradiction cannot empty every path of a run and discard the rest of the week. + */ + private fun honouredChoices( + cell: WeeklyReportBuilder.DayCell, + patrolProven: Boolean, + ): Pair { + val override = cell.manualOverride ?: return null to null + return override.attended?.takeUnless { !it && cell.attendanceProven } to + override.dailyPatrol?.takeUnless { !it && patrolProven } } private fun extends( @@ -1841,9 +1856,10 @@ internal object GunsmokeWeekSolver { } }.filter { option -> val override = cell.manualOverride + val (attended, patrol) = honouredChoices(cell, patrolProven = cell.hasDailyPatrolFact) (override?.attempts == null || override.attempts == option.attempts) && - (override?.attended == null || override.attended == option.attended) && - (override?.dailyPatrol == null || override.dailyPatrol == option.patrol) && + (attended == null || attended == option.attended) && + (patrol == null || patrol == option.patrol) && (!cell.hasLoginFact || option.attended) && (!cell.hasDailyPatrolFact || option.patrol) && ( diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt index 44265b0..7647c76 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt @@ -57,8 +57,12 @@ object WeeklyOverrideEditing { val stored = existingByCell[cell] val edit = editsByCell[cell] fun invalid(field: Field) = Result.Invalid(cell.first, cell.second, field) - val merit = edit?.merit?.let { parseCounter(it) ?: return invalid(Field.MERIT) } - val score = edit?.score?.let { parseCounter(it) ?: return invalid(Field.SCORE) } + val merit = edit?.merit?.let { + parseCounter(it, maximumDailyMerit(isGunsmokeWeek)) ?: return invalid(Field.MERIT) + } + val score = edit?.score?.let { + parseCounter(it, MAX_DAILY_SCORE) ?: return invalid(Field.SCORE) + } val attempts = edit?.attempts?.let { parseAttempts(it) ?: return invalid(Field.ATTEMPTS) } if (edit?.attended == Choice.NO && edit.dailyPatrol == Choice.YES) { return invalid(Field.ACTIVITY) @@ -97,15 +101,45 @@ object WeeklyOverrideEditing { return Result.Valid(merged.sortedWith(compareBy({ it.gameDay }, { it.uid }))) } + /** + * Whether [edit] sets Login or Daily Patrol to "no" although [cell]'s evidence proves it: a + * Daily Patrol event, a login time on the day, or a positive score or attempt count. The cell + * would keep showing "yes", so the editor refuses such an edit instead of storing it. + */ + fun contradictsEvidence(cell: WeeklyReportBuilder.DayCell, edit: CellEdit): Boolean { + fun positive(edited: String?, shown: Long?): Boolean = + (if (edited != null) edited.trim().toLongOrNull() else shown)?.let { it > 0L } == true + val loginProven = cell.hasDailyPatrolFact || cell.hasLoginFact || + positive(edit.score, cell.scoreDelta) || + positive(edit.attempts, cell.attempts?.toLong()) + return (edit.dailyPatrol == Choice.NO && cell.hasDailyPatrolFact) || + (edit.attended == Choice.NO && loginProven) + } + private class Parsed(val value: T?) - /** Blank clears the field; otherwise a non-negative integer is required. */ - private fun parseCounter(text: String): Parsed? { + /** Blank clears the field; otherwise an integer from 0 to [maximum] is required. */ + private fun parseCounter(text: String, maximum: Long): Parsed? { val trimmed = text.trim() if (trimmed.isEmpty()) return Parsed(null) - return trimmed.toLongOrNull()?.takeIf { it >= 0 }?.let { Parsed(it) } + return trimmed.toLongOrNull()?.takeIf { it in 0..maximum }?.let { Parsed(it) } } + /** + * A Standard day never earns more than its login and Daily Patrol. A Gunsmoke day adds its + * attempts and the merit of at most [MAX_DAILY_SCORE] points. + */ + internal fun maximumDailyMerit(isGunsmokeWeek: Boolean): Long = if (isGunsmokeWeek) { + ActivityInference.LOGIN_AND_PATROL_MERIT + + ActivityInference.MAX_DAILY_ATTEMPTS * ActivityInference.MERIT_PER_ATTEMPT + + MAX_DAILY_SCORE / ActivityInference.SCORE_POINTS_PER_MERIT + } else { + ActivityInference.LOGIN_AND_PATROL_MERIT + } + + /** A generous ceiling for one day's Gunsmoke score that keeps every weekly sum exact. */ + internal const val MAX_DAILY_SCORE = 1_000_000_000L + private fun parseAttempts(text: String): Parsed? { val trimmed = text.trim() if (trimmed.isEmpty()) return Parsed(null) 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 86343bb..69a7e17 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt @@ -228,13 +228,15 @@ object WeeklyReportBuilder { else -> MetricCertainty.LOWER_BOUND } + /** Whether the displayed Login is yes ahead of a manual choice, which then yields to it. */ + val attendanceProven: Boolean + get() = hasDailyPatrolFact || manualOverride?.dailyPatrol == true || hasLoginFact || + scoreDelta?.let { it > 0L } == true || + attempts?.let { it > 0 } == true || + inference?.attended == true + val attended: Boolean? - get() = true.takeIf { - hasDailyPatrolFact || manualOverride?.dailyPatrol == true || hasLoginFact || - scoreDelta?.let { it > 0L } == true || - attempts?.let { it > 0 } == true || - inference?.attended == true - } ?: manualOverride?.attended + get() = true.takeIf { attendanceProven } ?: manualOverride?.attended ?: solvedAttended ?: inference?.attended?.takeIf { evidence == DailyEvidence.ATTRIBUTED } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fb3f4ec..b40c662 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -606,6 +606,8 @@ 캡처를 아직 중지하는 중입니다 캡처가 끝나지 않아 아무것도 바꾸지 않았습니다. 홈에 준비됨이 표시되면 다시 시도하세요. 출석이 아니요이면 과업을 예로 둘 수 없습니다. + 기록된 활동에 이미 이 출석 또는 과업이 있어 아니요로 둘 수 없습니다. + 0부터 %1$d 사이의 공적을 입력하거나 비워 두세요. 대기 모드 켜기 대기 모드 실행 중 화면을 누르면 대기 모드가 해제됩니다. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef8188d..067bd43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -614,6 +614,8 @@ 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. + Recorded activity already shows this login or Patrol, so it can\'t be no. + Enter a merit from 0 to %1$d, or leave it blank. Turn on standby mode Standby mode is on Tap the screen to leave standby mode. 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 0f896cb..d1b3415 100644 --- a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt @@ -67,6 +67,62 @@ class GunsmokeWeekSolverTest { assertEquals(false, closed(periodStart.minusSeconds(26 * 60 * 60), periodStart.minusSeconds(27 * 60 * 60))) } + @Test + fun aStoredNoThatAPatrolFactDisprovesDoesNotDiscardTheRestOfTheWeek() { + // Exact 05:00 captures of a member who logs in, patrols, and plays one 5,000-point + // attempt every day: 50 + 40 + 30 + 500 = 620 merit a day. + val dailyMerit = 620L + val snapshots = (0..7).map { completedDays -> + PlatoonSnapshot( + id = completedDays.toLong(), + capturedAt = start.plusDays(completedDays.toLong()).atTime(5, 0).atZone(zone).toInstant(), + members = listOf( + member( + totalMerit = 10_000 + completedDays * dailyMerit, + totalScore = completedDays * 5_000L, + weeklyMerit = when (completedDays) { + 0 -> 540 + 1 -> 540 + dailyMerit + else -> (completedDays - 1) * dailyMerit + }, + ), + ), + ) + } + val wednesday = start.plusDays(3) + val facts = listOf(DailyPatrolFact(UID, wednesday.atTime(12, 0).atZone(zone).toInstant())) + + fun row(overrides: List) = WeeklyReportBuilder.build( + referenceDay = start, + zoneId = zone, + snapshots = snapshots, + overrides = overrides, + dailyPatrolFacts = facts, + asOf = start.plusDays(8).atStartOfDay(zone).toInstant(), + ).members.single() + + val clean = row(emptyList()) + val contradicted = row( + listOf(WeeklyCellOverride(UID, start, wednesday, null, null, null, null, dailyPatrol = false)), + ) + + assertEquals(true, contradicted.days[3].dailyPatrol) + listOf(4, 5, 6).forEach { index -> + val expected = clean.days[index] + val actual = contradicted.days[index] + assertEquals(expected.meritDelta, actual.meritDelta) + assertEquals(expected.meritCertainty, actual.meritCertainty) + assertEquals(expected.attempts, actual.attempts) + assertEquals(expected.dailyPatrol, actual.dailyPatrol) + } + assertEquals(7 * dailyMerit, contradicted.totalMerit) + assertEquals(MetricCertainty.EXACT, contradicted.totalMeritCertainty) + assertEquals(7, contradicted.totalAttempts) + assertEquals(MetricCertainty.EXACT, contradicted.totalAttemptsCertainty) + assertEquals(7, contradicted.patrolDays) + assertEquals(MetricCertainty.EXACT, contradicted.patrolDaysCertainty) + } + @Test fun nearResetUncappedAnchorDoesNotCreditTheSaturdayTailToTheGunsmokeSunday() { // Sunday 04:55 with 500 weekly merit, then Saturday's Daily Patrol at 04:57 (+40). Sunday diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt index 6930aa1..8753f8e 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt @@ -142,4 +142,63 @@ class WeeklyOverrideEditingTest { } } } + + @Test + fun manualCountersStayWithinTheDaysDomain() { + fun merge(gunsmoke: Boolean, edit: CellEdit) = + WeeklyOverrideEditing.merge(week, gunsmoke, emptyList(), listOf(edit)) + + // A Standard day caps at its login and Daily Patrol. + assertEquals(Result.Invalid(7, day, Field.MERIT), merge(false, CellEdit(7, day, merit = "91"))) + assertEquals( + Result.Invalid(7, day, Field.MERIT), + merge(false, CellEdit(7, day, merit = Long.MAX_VALUE.toString())), + ) + assertEquals(listOf(stored(merit = 90)), valid(merge(false, CellEdit(7, day, merit = "90")))) + assertTrue(valid(merge(false, CellEdit(7, day, merit = ""))).isEmpty()) + // A Gunsmoke day also holds its attempts, but no sum can overflow. + assertEquals(listOf(stored(merit = 1_000)), valid(merge(true, CellEdit(7, day, merit = "1000")))) + assertEquals( + Result.Invalid(7, day, Field.MERIT), + merge(true, CellEdit(7, day, merit = Long.MAX_VALUE.toString())), + ) + assertEquals( + Result.Invalid(7, day, Field.SCORE), + merge(true, CellEdit(7, day, score = (WeeklyOverrideEditing.MAX_DAILY_SCORE + 1).toString())), + ) + assertEquals( + listOf(stored(score = WeeklyOverrideEditing.MAX_DAILY_SCORE)), + valid(merge(true, CellEdit(7, day, score = WeeklyOverrideEditing.MAX_DAILY_SCORE.toString()))), + ) + } + + @Test + fun aNoThatRecordedActivityDisprovesIsAContradiction() { + fun cell(patrolFact: Boolean = false, loginFact: Boolean = false, score: Long? = null) = + WeeklyReportBuilder.DayCell( + gameDay = day, + meritDelta = null, + scoreDelta = score, + inference = null, + evidence = DailyEvidence.PARTIAL_DAY, + hasDailyPatrolFact = patrolFact, + hasLoginFact = loginFact, + isGunsmokeWeek = true, + ) + val noPatrol = CellEdit(7, day, dailyPatrol = Choice.NO) + val noLogin = CellEdit(7, day, attended = Choice.NO) + + assertTrue(WeeklyOverrideEditing.contradictsEvidence(cell(patrolFact = true), noPatrol)) + assertTrue(WeeklyOverrideEditing.contradictsEvidence(cell(patrolFact = true), noLogin)) + assertTrue(WeeklyOverrideEditing.contradictsEvidence(cell(loginFact = true), noLogin)) + assertTrue(WeeklyOverrideEditing.contradictsEvidence(cell(score = 1_000), noLogin)) + assertTrue(WeeklyOverrideEditing.contradictsEvidence(cell(), noLogin.copy(attempts = "1"))) + // Correcting the score and attempts to 0 in the same edit removes the evidence they supplied. + assertTrue( + !WeeklyOverrideEditing.contradictsEvidence(cell(score = 1_000), noLogin.copy(score = "0", attempts = "0")), + ) + assertTrue(!WeeklyOverrideEditing.contradictsEvidence(cell(loginFact = true), noPatrol)) + assertTrue(!WeeklyOverrideEditing.contradictsEvidence(cell(), noLogin)) + assertTrue(!WeeklyOverrideEditing.contradictsEvidence(cell(patrolFact = true), CellEdit(7, day, attended = Choice.UNSET))) + } } From 80e582fa1e90f4f372d64c242b2e44fc2516498f Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:06:33 +0900 Subject: [PATCH 27/98] Stop formatting a history entry once it exceeds the size limit The formatter writes into a bounded buffer and gives up as soon as the text would pass the entry limit, so a crafted payload can no longer allocate its whole oversized text before the 2 MiB check rejects it. Findings: sec-capture-m1 --- .../gf2log/app/history/CaptureHistoryStore.kt | 7 ++- .../app/history/CaptureHistoryStoreTest.kt | 32 ++++++++++++ .../protocol/ParsedPayloadTextFormatter.kt | 49 ++++++++++++++++++- .../ParsedPayloadTextFormatterTest.kt | 18 +++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt b/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt index fc50267..9b158a6 100644 --- a/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt +++ b/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt @@ -20,8 +20,11 @@ class CaptureHistoryStore( fun save(payload: ParsedPayload): Entry = synchronized(entries.lock) { entries.ensureExists("history") val instant = Instant.now(clock) - val content = ParsedPayloadTextFormatter.format(payload, LogTime.format(instant)) - .toByteArray(Charsets.UTF_8) + // Bounded while formatting, so a crafted payload cannot allocate its whole text first. + val text = requireNotNull( + ParsedPayloadTextFormatter.format(payload, LogTime.format(instant), MAX_ENTRY_BYTES), + ) { "Parsed-packet history entry exceeds the storage limit" } + val content = text.toByteArray(Charsets.UTF_8) require(content.size <= MAX_ENTRY_BYTES) { "Parsed-packet history entry exceeds the storage limit" } diff --git a/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt b/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt index 8bf4140..fa974c9 100644 --- a/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt @@ -1,5 +1,9 @@ package dev.gf2log.app.history +import dev.gf2log.protocol.Gfl2PayloadDecoder +import dev.gf2log.protocol.GuildMembersCsv +import dev.gf2log.protocol.model.GuildMember +import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.PublicSkillItem import dev.gf2log.protocol.model.PublicSkillItemsData import dev.gf2log.protocol.model.ParsedPayload @@ -11,6 +15,7 @@ import java.time.ZoneOffset import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test @@ -113,6 +118,33 @@ class CaptureHistoryStoreTest { } } + @Test + fun oversizedPayloadWritesNothingAndKeepsEarlierEntries() { + val directory = Files.createTempDirectory("gf2log-history-oversized-test").toFile() + try { + val store = CaptureHistoryStore(directory) + val kept = store.save(payload(1)) + val name = "x".repeat(GuildMembersCsv.MAX_MEMBER_NAME_CHARS) + val oversized = ParsedPayload( + messageId = 7, + payloadType = Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, + isEndOfMessage = true, + data = GuildMembersData( + List(CaptureHistoryStore.MAX_ENTRY_BYTES / name.length) { index -> + GuildMember((index + 1).toUInt(), name, 60u, 1u, 2u, 3u, 4u, 5u) + }, + ), + ) + + assertThrows(IllegalArgumentException::class.java) { store.save(oversized) } + + assertEquals(listOf(kept.id), store.list().map { it.id }) + assertEquals(listOf(kept.id), directory.list().orEmpty().toList()) + } finally { + directory.deleteRecursively() + } + } + private fun payload(index: Int): ParsedPayload = ParsedPayload( messageId = index, payloadType = 11138, diff --git a/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt b/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt index 9aa03a7..aedc3b0 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/ParsedPayloadTextFormatter.kt @@ -11,7 +11,27 @@ import dev.gf2log.protocol.model.WeaponModsData import dev.gf2log.protocol.model.WeaponsData object ParsedPayloadTextFormatter { - fun format(payload: ParsedPayload, capturedAt: String): String = buildString { + fun format(payload: ParsedPayload, capturedAt: String): String = + checkNotNull(format(payload, capturedAt, Int.MAX_VALUE)) + + /** + * Formats [payload], or returns null as soon as the text would exceed [maxChars], before a + * crafted payload can allocate its whole oversized text. A UTF-8 encoding is never shorter + * than its UTF-16 unit count, so a byte limit can be passed as [maxChars] directly. + */ + fun format(payload: ParsedPayload, capturedAt: String, maxChars: Int): String? { + require(maxChars >= 0) { "Character limit must not be negative" } + // The trailing line break is trimmed after writing, so it may briefly pass the limit. + val text = BoundedText(maxChars.toLong() + 1) + try { + text.write(payload, capturedAt) + } catch (_: BoundedText.LimitExceeded) { + return null + } + return text.toString().trimEnd().takeIf { it.length <= maxChars } + } + + private fun Appendable.write(payload: ParsedPayload, capturedAt: String) { appendLine("capturedAt=$capturedAt") appendLine("messageId=${payload.messageId}") appendLine("payloadType=${payload.payloadType}") @@ -129,6 +149,31 @@ object ParsedPayloadTextFormatter { } } } - }.trimEnd() + } + + /** A text buffer that refuses to grow past [maxChars]. */ + private class BoundedText(private val maxChars: Long) : Appendable { + private val builder = StringBuilder() + + override fun append(value: CharSequence?): Appendable { + val text = value ?: "null" + return append(text, 0, text.length) + } + + override fun append(value: CharSequence?, start: Int, end: Int): Appendable { + if (builder.length.toLong() + (end - start) > maxChars) throw LimitExceeded() + builder.append(value ?: "null", start, end) + return this + } + + override fun append(value: Char): Appendable { + if (builder.length.toLong() + 1 > maxChars) throw LimitExceeded() + builder.append(value) + return this + } + + override fun toString(): String = builder.toString() + class LimitExceeded : RuntimeException(null, null, false, false) + } } diff --git a/protocol/src/test/java/dev/gf2log/protocol/ParsedPayloadTextFormatterTest.kt b/protocol/src/test/java/dev/gf2log/protocol/ParsedPayloadTextFormatterTest.kt index f4ede3e..4d492f6 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/ParsedPayloadTextFormatterTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/ParsedPayloadTextFormatterTest.kt @@ -9,6 +9,8 @@ import dev.gf2log.protocol.model.PlatoonActivitySummary import dev.gf2log.protocol.model.PlatoonUpdateEntry import dev.gf2log.protocol.model.PlatoonUpdateMember import dev.gf2log.protocol.model.PlatoonUpdatesData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -33,6 +35,22 @@ class ParsedPayloadTextFormatterTest { assertTrue(text.contains(GuildMembersCsv.HEADER)) } + @Test + fun boundedFormattingStopsBeforeExceedingItsLimit() { + val payload = ParsedPayload( + messageId = 7, + payloadType = Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, + isEndOfMessage = true, + data = GuildMembersData(List(200) { member((it + 1).toUInt(), "Member $it") }), + ) + val capturedAt = "2026-07-22T08:52:37Z" + val full = ParsedPayloadTextFormatter.format(payload, capturedAt) + + assertNull(ParsedPayloadTextFormatter.format(payload, capturedAt, 512)) + assertNull(ParsedPayloadTextFormatter.format(payload, capturedAt, full.length - 1)) + assertEquals(full, ParsedPayloadTextFormatter.format(payload, capturedAt, full.length)) + } + @Test fun guildPacketHistoryNeutralizesFormulaLikeNamesWithoutChangingRetainedRows() { val formulaMember = member(1u, "=HYPERLINK(\"https://invalid\")") From 10d00219716429c866d22d5c5504d57656992ee2 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:06:45 +0900 Subject: [PATCH 28/98] Sync history and roster files before renaming them into place AtomicFiles and the roster CSV writer now sync the temporary file's data before the rename, as the import store already does, so a power loss cannot leave a zero-length or truncated file under its final name. Findings: data-sqlite-corruption-poison-m2 --- .../app/capture/GuildMembersCsvWriter.kt | 9 +++++- .../dev/gf2log/app/history/AtomicFiles.kt | 30 +++++++++++++++-- .../app/capture/GuildMembersCsvWriterTest.kt | 22 +++++++++++++ .../dev/gf2log/app/history/AtomicFilesTest.kt | 32 +++++++++++++++++++ 4 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/history/AtomicFilesTest.kt 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 46cf00a..e354e07 100644 --- a/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt +++ b/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt @@ -7,6 +7,7 @@ import dev.gf2log.protocol.model.GuildMember import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.ParsedPayload import java.io.File +import java.io.FileOutputStream import java.time.Clock import java.time.Instant import java.time.ZoneOffset @@ -19,12 +20,15 @@ import java.time.format.DateTimeFormatter * 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; it is deleted when * writing or publication fails, and [deleteStalePartials] removes any a process death left behind. + * The working file's data is synced before it is renamed into place, so a power loss can never + * leave an empty or truncated CSV under its final name. */ class GuildMembersCsvWriter( private val outputDirectory: File, private val clock: Clock = Clock.systemUTC(), private val publisher: (File, File) -> Unit = AtomicFiles::move, private val onBatchClosed: (CompletedBatch) -> Unit = {}, + private val sync: (FileOutputStream) -> Unit = AtomicFiles::sync, ) { @Synchronized fun accept(payload: ParsedPayload): SaveResult? { @@ -44,11 +48,14 @@ class GuildMembersCsvWriter( private fun publish(members: List): SaveResult { val batch = reserve(Instant.now(clock), members) try { - batch.workingFile.bufferedWriter(Charsets.UTF_8).use { writer -> + AtomicFiles.writeSynced(batch.workingFile, sync) { output -> + // Flushed rather than closed: writeSynced syncs and then closes the stream. + val writer = output.bufferedWriter(Charsets.UTF_8) writer.appendLine(GuildMembersCsv.HEADER) members.forEach { member -> writer.appendLine(GuildMembersCsv.row(member, batch.completed.logTime)) } + writer.flush() } publishWithRetry(batch.workingFile, batch.completed.file) } catch (error: Exception) { diff --git a/app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt b/app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt index e61fd9f..0f2dd6e 100644 --- a/app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt +++ b/app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt @@ -1,6 +1,8 @@ package dev.gf2log.app.history import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption @@ -16,13 +18,37 @@ internal object AtomicFiles { } /** Writes through a sibling temporary file so readers never observe partial content. */ - fun write(destination: File, content: ByteArray, temporaryPrefix: String) { + fun write( + destination: File, + content: ByteArray, + temporaryPrefix: String, + sync: (FileOutputStream) -> Unit = ::sync, + ) { val temporary = File.createTempFile(temporaryPrefix, ".tmp", destination.parentFile) try { - temporary.writeBytes(content) + writeSynced(temporary, sync) { it.write(content) } move(temporary, destination) } finally { temporary.delete() } } + + /** + * Writes [file] through [write] and syncs its data before returning. A rename orders only the + * name, not the data, so a file renamed into place unsynced can survive a power loss under its + * final name with zero length. + */ + fun writeSynced( + file: File, + sync: (FileOutputStream) -> Unit = ::sync, + write: (OutputStream) -> Unit, + ) { + FileOutputStream(file).use { output -> + write(output) + output.flush() + sync(output) + } + } + + fun sync(output: FileOutputStream) = output.fd.sync() } 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 7e075d5..ed1bf52 100644 --- a/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt @@ -97,6 +97,28 @@ class GuildMembersCsvWriterTest { assertEquals("Current", parsed.members.single().name) } + @Test + fun workingFileIsCompleteAndSyncedBeforeItIsPublished() { + val output = temporaryFolder.newFolder("synced-publication") + val events = mutableListOf() + val writer = GuildMembersCsvWriter( + outputDirectory = output, + publisher = { working, final -> + events += "publish:" + working.readText(Charsets.UTF_8).lines().size + assertTrue(working.renameTo(final)) + }, + sync = { stream -> + events += "sync" + stream.fd.sync() + }, + ) + + writer.accept(payload(42, end = true, member(1u, "One"), member(2u, "Two"))) + + // The header, two rows, and the empty remainder after the final line break. + assertEquals(listOf("sync", "publish:4"), events) + } + @Test fun transientPublicationFailureRetriesImmediately() { val output = temporaryFolder.newFolder("publication-failure") diff --git a/app/src/test/java/dev/gf2log/app/history/AtomicFilesTest.kt b/app/src/test/java/dev/gf2log/app/history/AtomicFilesTest.kt new file mode 100644 index 0000000..ee72b78 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/history/AtomicFilesTest.kt @@ -0,0 +1,32 @@ +package dev.gf2log.app.history + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class AtomicFilesTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun contentIsSyncedBeforeTheDestinationNameAppears() { + val directory = temporaryFolder.newFolder("atomic") + val destination = directory.resolve("entry.txt") + val content = "complete entry".toByteArray(Charsets.UTF_8) + var syncs = 0 + + AtomicFiles.write(destination, content, "entry_") { stream -> + syncs += 1 + assertFalse("The destination must not exist before its data is synced", destination.exists()) + assertEquals(content.size.toLong(), directory.listFiles().orEmpty().single().length()) + stream.fd.sync() + } + + assertEquals(1, syncs) + assertArrayEquals(content, destination.readBytes()) + assertEquals(listOf("entry.txt"), directory.list().orEmpty().toList()) + } +} From 12ef099ac51c553eaaaec178dcdeb42672929ed7 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:06:55 +0900 Subject: [PATCH 29/98] Rotate recent history by a persisted recording sequence Each entry ID now carries a sequence one above the highest already in the directory, and rotation deletes the lowest sequences first, never the entry just saved. Entries from earlier versions, whose counter was process-local, count as recorded first. A clock moved backwards therefore no longer deletes each new capture as soon as it is written. Findings: data-doc-contracts-8 --- .../gf2log/app/history/CaptureHistoryStore.kt | 41 +++++++++++---- .../app/history/CaptureHistoryStoreTest.kt | 51 +++++++++++++++++++ docs/ARCHITECTURE.md | 7 ++- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt b/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt index 9b158a6..044f327 100644 --- a/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt +++ b/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt @@ -7,9 +7,12 @@ import java.io.File import java.time.Clock import java.time.Instant import java.time.ZoneId -import java.util.concurrent.atomic.AtomicLong -/** Recent parsed-packet history; each save rotates out the oldest entries beyond [MAX_ENTRIES]. */ +/** + * Recent parsed-packet history; each save rotates out the earliest-recorded entries beyond + * [MAX_ENTRIES]. Recording order comes from a sequence persisted in each entry ID, not from the + * wall clock, so a clock moved backwards never makes a new capture the first one rotated out. + */ class CaptureHistoryStore( directory: File, private val clock: Clock = Clock.systemUTC(), @@ -28,12 +31,13 @@ class CaptureHistoryStore( require(content.size <= MAX_ENTRY_BYTES) { "Parsed-packet history entry exceeds the storage limit" } - val id = "${instant.toEpochMilli()}_${payload.payloadType}_${sequence.incrementAndGet()}" + + val sequence = (entries.files().mapNotNull(::recordingSequence).maxOrNull() ?: 0L) + 1 + val id = "${instant.toEpochMilli()}_${payload.payloadType}_$SEQUENCE_PREFIX$sequence" + ".${HistoryDirectory.ENTRY_EXTENSION}" val destination = File(entries.directory, id) AtomicFiles.write(destination, content, "packet_") destination.setLastModified(instant.toEpochMilli()) - trimToLimit() + trimToLimit(keep = destination) entries.toEntry(destination) } @@ -43,12 +47,31 @@ class CaptureHistoryStore( fun delete(ids: Collection): Int = entries.delete(ids) - private fun trimToLimit() { - val oldestFirst = entries.files() - .sortedWith(compareBy { it.lastModified() }.thenBy { it.name }) - oldestFirst.take((oldestFirst.size - MAX_ENTRIES).coerceAtLeast(0)).forEach(File::delete) + /** + * Deletes the earliest-recorded entries beyond [MAX_ENTRIES], never [keep]. Entries written + * before IDs carried a persisted sequence count as recorded first, in modification order. + */ + private fun trimToLimit(keep: File) { + val files = entries.files() + files.filter { it.name != keep.name } + .sortedWith( + compareBy { recordingSequence(it) ?: 0L } + .thenBy { it.lastModified() } + .thenBy { it.name }, + ) + .take((files.size - MAX_ENTRIES).coerceAtLeast(0)) + .forEach(File::delete) } + /** The persisted recording sequence in an entry ID, or null for an ID written without one. */ + private fun recordingSequence(file: File): Long? = file.nameWithoutExtension + .split('_') + .getOrNull(2) + ?.takeIf { it.startsWith(SEQUENCE_PREFIX) } + ?.removePrefix(SEQUENCE_PREFIX) + ?.toLongOrNull() + ?.takeIf { it > 0 } + data class Entry( val id: String, val title: String, @@ -59,6 +82,6 @@ class CaptureHistoryStore( const val MAX_ENTRIES = 100 const val MAX_ENTRY_BYTES = 2 * 1024 * 1024 const val HISTORY_DIRECTORY = "capture-history" - private val sequence = AtomicLong() + private const val SEQUENCE_PREFIX = "s" } } diff --git a/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt b/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt index fa974c9..74edeea 100644 --- a/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt @@ -118,6 +118,57 @@ class CaptureHistoryStoreTest { } } + @Test + fun aClockMovedBackwardsNeverRotatesOutTheEntryJustSaved() { + val directory = Files.createTempDirectory("gf2log-history-clock-test").toFile() + try { + val start = Instant.parse("2026-07-22T08:00:00Z") + val original = Clock.fixed(start, ZoneOffset.UTC) + val ids = (0 until CaptureHistoryStore.MAX_ENTRIES).map { index -> + CaptureHistoryStore(directory, original).save(payload(index)).id + } + val rewound = Clock.fixed(start.minusSeconds(3_600), ZoneOffset.UTC) + + val first = CaptureHistoryStore(directory, rewound).save(payload(1_000)) + val second = CaptureHistoryStore(directory, rewound).save(payload(1_001)) + + val listed = CaptureHistoryStore(directory).list().map { it.id } + assertEquals(CaptureHistoryStore.MAX_ENTRIES, listed.size) + assertTrue(first.id in listed) + assertTrue(second.id in listed) + assertFalse(ids[0] in listed) + assertFalse(ids[1] in listed) + assertTrue(ids[2] in listed) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun entriesWithoutAPersistedSequenceAreRotatedOutFirst() { + val directory = Files.createTempDirectory("gf2log-history-legacy-test").toFile() + try { + val clock = Clock.fixed(Instant.parse("2026-07-22T08:00:00Z"), ZoneOffset.UTC) + val sequenced = CaptureHistoryStore(directory, clock).save(payload(0)).id + repeat(CaptureHistoryStore.MAX_ENTRIES - 1) { index -> + // A legacy ID carries a process-local counter that says nothing about order. + directory.resolve("${index + 1}_11138_${500 - index}.txt").apply { + writeText("legacy $index") + setLastModified(Instant.parse("2026-08-01T00:00:00Z").toEpochMilli() + index) + } + } + + CaptureHistoryStore(directory, clock).save(payload(1)) + + val listed = CaptureHistoryStore(directory).list().map { it.id } + assertEquals(CaptureHistoryStore.MAX_ENTRIES, listed.size) + assertTrue(sequenced in listed) + assertFalse("1_11138_500.txt" in listed) + } finally { + directory.deleteRecursively() + } + } + @Test fun oversizedPayloadWritesNothingAndKeepsEarlierEntries() { val directory = Files.createTempDirectory("gf2log-history-oversized-test").toFile() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8937807..d6a7dc3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -375,8 +375,11 @@ Every completed recognized payload is formatted in protocol order and written atomically below the admitted profile's private `files/platoons//capture-history` directory. Unconfirmed payloads never enter history. `CaptureHistoryStore` returns entries newest-first, trims -the oldest files once the count exceeds 100, rejects path-like identifiers, and -supports explicit deletion of user-selected entries. `SavedHistoryStore` +the earliest-recorded files once the count exceeds 100, rejects path-like +identifiers, and supports explicit deletion of user-selected entries. Recording +order is a sequence persisted in each entry ID rather than the wall clock, so a +clock moved backwards never rotates out the entry just saved. Every history and +roster file is synced before it is renamed into place. `SavedHistoryStore` atomically copies selected entries into the same profile's `saved-history` directory, rejects duplicates, caps the collection at 50 without FIFO deletion, and keeps saved entries independent from recent-history rotation. From ce5d89d589790ca6424ce4a72a5545ec7e2ab082 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:07:27 +0900 Subject: [PATCH 30/98] Keep quoted CSV text exact and render every accepted roster The CSV tokenizer no longer normalizes line endings across the whole document: an unquoted CR, LF or CRLF ends a record, while a carriage return inside a quoted name is kept, so retained rosters read back the same name live capture stored. The history envelope is split without touching the body. The packet table's row and cell limits are derived from the roster limits, so a valid 227 to 256 member roster renders and uploads again. Findings: sec-protocol-m1, sec-protocol-m3 --- .../app/discord/OriginalCsvPayloadTest.kt | 40 +++++++++++++++++++ docs/ARCHITECTURE.md | 9 +++-- .../dev/gf2log/protocol/GuildMembersCsv.kt | 5 ++- .../protocol/ParsedPacketTableParser.kt | 28 +++++++++---- .../gf2log/protocol/internal/CsvRecords.kt | 22 +++++----- .../dev/gf2log/protocol/CsvRecordsTest.kt | 12 +++++- .../protocol/ParsedPacketTableParserTest.kt | 32 ++++++++++++++- 7 files changed, 122 insertions(+), 26 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/discord/OriginalCsvPayloadTest.kt diff --git a/app/src/test/java/dev/gf2log/app/discord/OriginalCsvPayloadTest.kt b/app/src/test/java/dev/gf2log/app/discord/OriginalCsvPayloadTest.kt new file mode 100644 index 0000000..578b79f --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/discord/OriginalCsvPayloadTest.kt @@ -0,0 +1,40 @@ +package dev.gf2log.app.discord + +import dev.gf2log.protocol.Gfl2PayloadDecoder +import dev.gf2log.protocol.GuildMembersCsv +import dev.gf2log.protocol.ParsedPacketTableParser +import dev.gf2log.protocol.ParsedPayloadTextFormatter +import dev.gf2log.protocol.model.GuildMember +import dev.gf2log.protocol.model.GuildMembersData +import dev.gf2log.protocol.model.ParsedPayload +import org.junit.Assert.assertEquals +import org.junit.Test + +class OriginalCsvPayloadTest { + @Test + fun theLargestAcceptedRosterWithTheWidestNamesStillUploads() { + // Three UTF-8 bytes per name unit is the most a name within the UTF-16 limit can use. + val name = "가".repeat(GuildMembersCsv.MAX_MEMBER_NAME_CHARS) + val members = (1..GuildMembersCsv.MAX_ROSTER_MEMBERS).map { index -> + GuildMember( + uid = UInt.MAX_VALUE - index.toUInt(), + name = name, + level = UInt.MAX_VALUE, + weeklyMerit = UInt.MAX_VALUE, + totalMerit = UInt.MAX_VALUE, + highScore = UInt.MAX_VALUE, + totalScore = UInt.MAX_VALUE, + lastLogin = UInt.MAX_VALUE, + ) + } + val history = ParsedPayloadTextFormatter.format( + ParsedPayload(7, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, true, GuildMembersData(members)), + "2026-08-08T00:00:00Z", + ) + + val body = OriginalCsvPayload.extract(history) + + assertEquals(GuildMembersCsv.MAX_ROSTER_MEMBERS, ParsedPacketTableParser.parse(history)?.rows?.size) + assertEquals(GuildMembersCsv.MAX_ROSTER_MEMBERS + 1, body.lines().size) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d6a7dc3..a700d47 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -158,10 +158,11 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. quarantines its flow, so an earlier queued open callback cannot recreate stale metadata. - Queue saturation is counted and surfaced in the capture status instead of being silently discarded. - Raw IP packets and application payloads are not persisted. -- One parsed history entry is capped at 2 MiB. The packet-table projection - accepts at most 512 KiB, 250 rows, 16 columns, 2,048 cells, and 8 KiB per - cell; rejected content remains available only through the already bounded raw - history view rather than constructing an unbounded Android view hierarchy. +- One parsed history entry is capped at 2 MiB, enforced while it is formatted. + The packet-table projection accepts at most 512 KiB, 256 rows (the roster + member limit), 16 columns, 2,313 cells, and 8 KiB per cell; rejected content + remains available only through the already bounded raw history view rather + than constructing an unbounded Android view hierarchy. CSV retained as internal roster evidence preserves protocol text exactly. Explicit spreadsheet-facing roster, member, comparison, and weekly exports pass diff --git a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt index 6cf4c3e..d83ff31 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt @@ -65,7 +65,7 @@ object GuildMembersCsv { // - Returns null when any required field is malformed or capture times disagree. fun parse(content: String): Snapshot? { if (content.length > MAX_CONTENT_CHARS) return null - val records = CsvRecords.parse(content, MAX_COLUMNS, MAX_RECORDS, MAX_FIELD_CHARS) + val records = CsvRecords.parse(content, COLUMN_COUNT, MAX_RECORDS, MAX_FIELD_CHARS) ?: return null val header = records.firstOrNull() ?: return null if (header != HEADER.split(',')) return null @@ -119,7 +119,8 @@ object GuildMembersCsv { private const val MAX_CONTENT_CHARS = 2 * 1024 * 1024 const val MAX_ROSTER_MEMBERS = 256 const val MAX_MEMBER_NAME_CHARS = 256 - private const val MAX_COLUMNS = 9 + /** The number of columns in [HEADER]. */ + const val COLUMN_COUNT = 9 private const val MAX_RECORDS = MAX_ROSTER_MEMBERS + 2 private const val MAX_FIELD_CHARS = 512 } diff --git a/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt b/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt index 5b53c62..0ca70f3 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt @@ -21,12 +21,25 @@ object ParsedPacketTableParser { ) } - /** Returns the line-ending-normalized CSV after a history envelope's blank separator line. */ + /** + * Returns the CSV after a history envelope's blank separator line. The body keeps its line + * endings, so a quoted carriage return in a member name survives to the table and upload. + */ fun csvBody(content: String): String? { - val normalized = CsvRecords.normalizeLineEndings(content) - val separator = normalized.indexOf("\n\n") - if (separator < 0 || separator + 2 >= normalized.length) return null - return normalized.substring(separator + 2) + var index = 0 + var previousWasLineBreak = false + while (index < content.length) { + val character = content[index] + if (character != '\r' && character != '\n') { + previousWasLineBreak = false + index += 1 + continue + } + index += if (character == '\r' && content.getOrNull(index + 1) == '\n') 2 else 1 + if (previousWasLineBreak) return content.substring(index).takeIf(String::isNotEmpty) + previousWasLineBreak = true + } + return null } data class Table( @@ -35,9 +48,10 @@ object ParsedPacketTableParser { ) const val MAX_CONTENT_CHARS = 512 * 1024 - const val MAX_ROWS = 250 + /** Every roster that [GuildMembersCsv.isValidRoster] accepts must render and upload. */ + const val MAX_ROWS = GuildMembersCsv.MAX_ROSTER_MEMBERS const val MAX_COLUMNS = 16 - const val MAX_CELLS = 2_048 + const val MAX_CELLS = (MAX_ROWS + 1) * GuildMembersCsv.COLUMN_COUNT const val MAX_CELL_CHARS = 8 * 1024 private const val MAX_RECORDS = MAX_ROWS + 1 } diff --git a/protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt b/protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt index 758b11a..6a8744c 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt @@ -2,14 +2,16 @@ package dev.gf2log.protocol.internal /** Bounded RFC-4180-style tokenizer shared by every CSV reader in this module. */ internal object CsvRecords { - /** Returns null for unbalanced quotes or when any bound would be exceeded. */ + /** + * Returns null for unbalanced quotes or when any bound would be exceeded. Quoted fields keep + * their content exactly, line breaks included; only an unquoted CRLF, CR, or LF ends a record. + */ fun parse( content: String, maxColumns: Int, maxRecords: Int, maxFieldChars: Int, ): List>? { - val csv = normalizeLineEndings(content) val records = mutableListOf>() val row = mutableListOf() val field = StringBuilder() @@ -30,10 +32,10 @@ internal object CsvRecords { return true } - while (index < csv.length) { - when (val character = csv[index]) { + while (index < content.length) { + when (val character = content[index]) { '"' -> { - if (quoted && index + 1 < csv.length && csv[index + 1] == '"') { + if (quoted && index + 1 < content.length && content[index + 1] == '"') { field.append('"') index += 1 } else { @@ -41,10 +43,11 @@ internal object CsvRecords { } } ',' -> if (quoted) field.append(character) else if (!endField()) return null - '\n' -> if (quoted) { + '\r', '\n' -> if (quoted) { field.append(character) - } else if (!endField() || !endRecord()) { - return null + } else { + if (!endField() || !endRecord()) return null + if (character == '\r' && content.getOrNull(index + 1) == '\n') index += 1 } else -> { if (field.length >= maxFieldChars) return null @@ -60,7 +63,4 @@ internal object CsvRecords { } return records } - - fun normalizeLineEndings(content: String): String = - content.replace("\r\n", "\n").replace('\r', '\n') } diff --git a/protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt b/protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt index 4553b0d..02be7e7 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt @@ -9,11 +9,21 @@ class CsvRecordsTest { @Test fun quotedDelimitersLineBreaksAndEscapedQuotesStayInOneField() { assertEquals( - listOf(listOf("a,b", "line\nbreak", "say \"hi\""), listOf("2", "", "")), + listOf(listOf("a,b", "line\r\nbreak", "say \"hi\""), listOf("2", "", "")), CsvRecords.parse("\"a,b\",\"line\r\nbreak\",\"say \"\"hi\"\"\"\r\n2,,", 3, 2, 32), ) } + @Test + fun quotedCarriageReturnsArePreservedWhileUnquotedOnesEndRecords() { + assertEquals(listOf(listOf("a\rb")), CsvRecords.parse("\"a\rb\"", 1, 1, 8)) + assertEquals(listOf(listOf("a\r\nb")), CsvRecords.parse("\"a\r\nb\"\r\n", 1, 1, 8)) + assertEquals( + listOf(listOf("x"), listOf("y"), listOf("z")), + CsvRecords.parse("x\r\ny\rz\n", 1, 3, 8), + ) + } + @Test fun everyBoundAndUnbalancedQuoteRejectsTheDocument() { assertNull(CsvRecords.parse("a,b,c", maxColumns = 2, maxRecords = 1, maxFieldChars = 8)) diff --git a/protocol/src/test/java/dev/gf2log/protocol/ParsedPacketTableParserTest.kt b/protocol/src/test/java/dev/gf2log/protocol/ParsedPacketTableParserTest.kt index 4c62db4..91b400e 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/ParsedPacketTableParserTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/ParsedPacketTableParserTest.kt @@ -1,5 +1,8 @@ package dev.gf2log.protocol +import dev.gf2log.protocol.model.GuildMember +import dev.gf2log.protocol.model.GuildMembersData +import dev.gf2log.protocol.model.ParsedPayload import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -44,7 +47,9 @@ class ParsedPacketTableParserTest { .joinToString(",") val wideContent = metadata() + "$tooManyColumns\n" val header = List(16) { "c$it" }.joinToString(",") - val rows = List(128) { index -> List(16) { "$index" }.joinToString(",") } + val rows = List(ParsedPacketTableParser.MAX_CELLS / 16) { index -> + List(16) { "$index" }.joinToString(",") + } val tooManyCells = metadata() + (listOf(header) + rows).joinToString("\n") assertNull(ParsedPacketTableParser.parse(wideContent)) @@ -62,6 +67,31 @@ class ParsedPacketTableParserTest { assertNull(ParsedPacketTableParser.parse(largeCell)) } + @Test + fun everyAcceptedRosterSizeRendersFromItsHistoryEntry() { + val name = "\"".repeat(GuildMembersCsv.MAX_MEMBER_NAME_CHARS) + val members = (1..GuildMembersCsv.MAX_ROSTER_MEMBERS).map { index -> + GuildMember(index.toUInt(), name, 60u, 1u, 2u, 3u, 4u, 5u) + } + val content = ParsedPayloadTextFormatter.format( + ParsedPayload(7, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, true, GuildMembersData(members)), + "2026-08-08T00:00:00Z", + ) + + val table = ParsedPacketTableParser.parse(content) + + assertEquals(GuildMembersCsv.MAX_ROSTER_MEMBERS, table?.rows?.size) + assertEquals(name, table?.rows?.last()?.get(1)) + } + + @Test + fun bodyKeepsAQuotedCarriageReturnAndAcceptsCrlfEnvelopes() { + val content = "capturedAt=2026-08-08T00:00:00Z\r\nmessageId=1\r\n\r\nuid,name\r\n1,\"a\rb\"" + + assertEquals("uid,name\r\n1,\"a\rb\"", ParsedPacketTableParser.csvBody(content)) + assertEquals(listOf("1", "a\rb"), ParsedPacketTableParser.parse(content)?.rows?.single()) + } + private fun metadata(): String = "capturedAt=2026-08-08T00:00:00Z\nmessageId=1\npayloadType=21917\n\n" } From 31a9d7bef929dd87f812e34b8de6da663e14ce9b Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:07:30 +0900 Subject: [PATCH 31/98] Report roster comparison deltas with honest certainty A capture exactly at a 05:00 reset is the closing value of the day before, so it no longer turns an exact gain into a lower bound. A dropped Total Merit counter is a lower bound instead of an exact gain, carried to the badge, its description and a new CSV column. Login-only changes stay in the CSV but are no longer listed or counted as counter changes. Findings: q-weekly-engine-5, q-uiux-m2, q-uiux-9 --- .../gf2log/app/SnapshotComparisonActivity.kt | 18 ++++--- .../app/management/SnapshotComparison.kt | 35 +++++++----- .../app/management/SnapshotComparisonCsv.kt | 9 ++-- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../app/management/SnapshotComparisonTest.kt | 53 ++++++++++++++++++- 6 files changed, 94 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index faca57e..f15c068 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -93,19 +93,21 @@ class SnapshotComparisonActivity : BaseActivity() { TextRole.SUBTITLE, ), lp(top = 2)) addView(text( - getString(R.string.comparison_counts, result.joined.size, result.left.size, result.changes.size), + getString(R.string.comparison_counts, result.joined.size, result.left.size, result.counterChanges.size), TextRole.CAPTION, ), lp(top = 4)) }, lp(top = 4)) section(getString(R.string.joined_members), result.joined.size, result.joined.map { it.name to Labels.uid(this, it.uid) }) section(getString(R.string.departed_members), result.left.size, result.left.map { it.name to Labels.uid(this, it.uid) }) - content.addView(sectionHeader(getString(R.string.member_changes_count, result.changes.size)), lp()) - if (result.changes.isEmpty()) { + // Login-only changes have no counter to show; the CSV export still lists them. + val changes = result.counterChanges + content.addView(sectionHeader(getString(R.string.member_changes_count, changes.size)), lp()) + if (changes.isEmpty()) { content.addView(text(getString(R.string.none), TextRole.SECONDARY), lp(start = 4)) return } content.addView(panel(padding = 8) { - result.changes.forEachIndexed { index, change -> + changes.forEachIndexed { index, change -> if (index > 0) addView(divider(insetStartDp = 8)) addView(column { setPadding(dp(8), dp(8), dp(8), dp(8)) @@ -124,7 +126,11 @@ class SnapshotComparisonActivity : BaseActivity() { change.weeklyMeritDelta, change.weeklyMeritDeltaCertainty, ), - Triple(getString(R.string.total_merit), change.totalMeritDelta, MetricCertainty.EXACT), + Triple( + getString(R.string.total_merit), + change.totalMeritDelta, + change.totalMeritDeltaCertainty, + ), Triple( getString(R.string.gunsmoke_score), change.totalScoreDelta, @@ -142,7 +148,7 @@ class SnapshotComparisonActivity : BaseActivity() { R.string.member_change_description, change.name, WeeklyMetricPresentation.format(change.weeklyMeritDelta, change.weeklyMeritDeltaCertainty), - change.totalMeritDelta, + WeeklyMetricPresentation.format(change.totalMeritDelta, change.totalMeritDeltaCertainty), WeeklyMetricPresentation.format(change.totalScoreDelta, change.totalScoreDeltaCertainty), ) importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES 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 977f036..ed6d8df 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt @@ -15,7 +15,12 @@ object SnapshotComparison { val lastLoginChanged: Boolean, val weeklyMeritDeltaCertainty: MetricCertainty = MetricCertainty.EXACT, val totalScoreDeltaCertainty: MetricCertainty = MetricCertainty.EXACT, - ) + val totalMeritDeltaCertainty: MetricCertainty = MetricCertainty.EXACT, + ) { + /** Whether a counter changed; a member who only logged in has none to show. */ + val hasCounterChange: Boolean + get() = weeklyMeritDelta != 0L || totalMeritDelta != 0L || totalScoreDelta != 0L + } data class Result( val older: PlatoonSnapshot, @@ -23,12 +28,17 @@ object SnapshotComparison { val joined: List, val left: List, val changes: List, - ) + ) { + /** The changes the comparison screen lists; the CSV export keeps login-only rows too. */ + val counterChanges: List + get() = changes.filter(MeritChange::hasCounterChange) + } /** * 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. + * Total Merit never resets, so a drop in it is reported the same way rather than as a gain. */ fun compare(older: PlatoonSnapshot, newer: PlatoonSnapshot, zoneId: ZoneId): Result { require(!newer.capturedAt.isBefore(older.capturedAt)) @@ -51,22 +61,21 @@ object SnapshotComparison { resettingDelta(previous.weeklyMerit, current.weeklyMerit, weeklyReset) val (scoreDelta, scoreCertainty) = resettingDelta(previous.totalScore, current.totalScore, eventReset) + val (totalMeritDelta, totalMeritCertainty) = + resettingDelta(previous.totalMerit, current.totalMerit, resetBetween = false) MeritChange( uid = current.uid, name = current.name, weeklyMeritDelta = weeklyDelta, - totalMeritDelta = ActivityInference.counterDelta(previous.totalMerit, current.totalMerit), + totalMeritDelta = totalMeritDelta, totalScoreDelta = scoreDelta, lastLoginChanged = current.lastLogin != previous.lastLogin, weeklyMeritDeltaCertainty = weeklyCertainty, totalScoreDeltaCertainty = scoreCertainty, + totalMeritDeltaCertainty = totalMeritCertainty, ) - }.filter { - it.weeklyMeritDelta != 0L || - it.totalMeritDelta != 0L || - it.totalScoreDelta != 0L || - it.lastLoginChanged - }.sortedByDescending(MeritChange::weeklyMeritDelta), + }.filter { it.hasCounterChange || it.lastLoginChanged } + .sortedByDescending(MeritChange::weeklyMeritDelta), ) } @@ -82,8 +91,10 @@ object SnapshotComparison { } /** - * 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. + * Whether a 05:00 reset of a game day matching [isReset] falls within [from]..[to] and can + * separate the two counters. A reset at the older capture counts, since that capture may + * precede the restart. One at the newer capture does not: that capture is the closing value of + * the day before, and a restart it did see shows as a drop. */ private fun resetBetween( from: Instant, @@ -96,7 +107,7 @@ object SnapshotComparison { while (!day.isAfter(last)) { if (isReset(day)) { val resetAt = PlatoonPeriods.periodStartInstant(day, zoneId) - if (!resetAt.isBefore(from) && !resetAt.isAfter(to)) return true + if (!resetAt.isBefore(from) && resetAt.isBefore(to)) return true } day = day.plusDays(1) } 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 a061210..712dab6 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotComparisonCsv.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotComparisonCsv.kt @@ -6,15 +6,15 @@ object SnapshotComparisonCsv { const val HEADER = "olderCapturedAt,newerCapturedAt,changeType,uid,name,weeklyMeritDelta," + "totalMeritDelta,totalScoreDelta,lastLoginChanged,weeklyMeritDeltaCertainty," + - "totalScoreDeltaCertainty" + "totalScoreDeltaCertainty,totalMeritDeltaCertainty" fun format(result: SnapshotComparison.Result): String = buildString { appendLine(HEADER) result.joined.forEach { member -> - appendRow(result, "JOINED", member.uid, member.name, null, null, null, null, null, null) + appendRow(result, "JOINED", member.uid, member.name, null, null, null, null, null, null, null) } result.left.forEach { member -> - appendRow(result, "WITHDREW", member.uid, member.name, null, null, null, null, null, null) + appendRow(result, "WITHDREW", member.uid, member.name, null, null, null, null, null, null, null) } result.changes.forEach { change -> appendRow( @@ -28,6 +28,7 @@ object SnapshotComparisonCsv { change.lastLoginChanged, change.weeklyMeritDeltaCertainty, change.totalScoreDeltaCertainty, + change.totalMeritDeltaCertainty, ) } }.trimEnd() + "\n" @@ -43,6 +44,7 @@ object SnapshotComparisonCsv { lastLoginChanged: Boolean?, weeklyMeritCertainty: MetricCertainty?, totalScoreCertainty: MetricCertainty?, + totalMeritCertainty: MetricCertainty?, ) { appendLine( listOf( @@ -57,6 +59,7 @@ object SnapshotComparisonCsv { lastLoginChanged, weeklyMeritCertainty, totalScoreCertainty, + totalMeritCertainty, ).joinToString(",") { CsvCell.escape(it) }, ) } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b40c662..79dbed6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -408,7 +408,7 @@ Lv. %1$d 스냅샷을 비교하는 중… 멤버를 불러오는 중… - %1$s: 이번 주 공적 +%2$s, 누적 공적 +%3$d, 흙먼지 점수 +%4$s + %1$s: 이번 주 공적 +%2$s, 누적 공적 +%3$s, 흙먼지 점수 +%4$s 수치 변화 · %1$d 게임 닉네임 대신 모든 화면에 표시됩니다. %2$d명 중 %1$d번째, %3$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 067bd43..1e21441 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -412,7 +412,7 @@ Lv. %1$d Comparing snapshots… Loading members… - %1$s: merit this week +%2$s, total merit +%3$d, Gunsmoke score +%4$s + %1$s: merit this week +%2$s, total merit +%3$s, Gunsmoke score +%4$s Counter changes · %1$d Shown everywhere in place of the in-game name. %1$d of %2$d, %3$s 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 3d46e2a..76517d0 100644 --- a/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonTest.kt @@ -54,9 +54,58 @@ class SnapshotComparisonTest { assertEquals(MetricCertainty.LOWER_BOUND, change.totalScoreDeltaCertainty) } + @Test + fun aCaptureExactlyAtTheResetIsTheClosingValueOfTheDayBefore() { + // Sunday 20:00 to Monday 05:00:00, the instant of the weekly reset. + val older = snapshot("2026-07-26T11:00:00Z", member(1, "Member", 500, 10_000, 0)) + + fun change(time: String, weekly: Long) = SnapshotComparison.compare( + older, + snapshot(time, member(1, "Member", weekly, 10_090, 0)), + zone, + ).changes.single() + + val atReset = change("2026-07-26T20:00:00Z", 590) + assertEquals(90, atReset.weeklyMeritDelta) + assertEquals(MetricCertainty.EXACT, atReset.weeklyMeritDeltaCertainty) + + val afterReset = change("2026-07-26T20:00:01Z", 590) + assertEquals(590, afterReset.weeklyMeritDelta) + assertEquals(MetricCertainty.LOWER_BOUND, afterReset.weeklyMeritDeltaCertainty) + + // A capture at the reset that already saw the restart shows it as a drop. + val restarted = change("2026-07-26T20:00:00Z", 0) + assertEquals(0, restarted.weeklyMeritDelta) + assertEquals(MetricCertainty.LOWER_BOUND, restarted.weeklyMeritDeltaCertainty) + } + + @Test + fun aDroppedTotalMeritCounterIsNeverAnExactGain() { + val older = snapshot("2026-07-20T00:00:00Z", member(1, "Dropped", 100, 1_000, 0), member(2, "Grew", 100, 500, 0)) + val newer = snapshot("2026-07-21T00:00:00Z", member(1, "Dropped", 100, 500, 0), member(2, "Grew", 100, 700, 0)) + + val changes = SnapshotComparison.compare(older, newer, zone).changes.associateBy { it.uid } + + assertEquals(MetricCertainty.LOWER_BOUND, changes.getValue(1).totalMeritDeltaCertainty) + assertEquals(200, changes.getValue(2).totalMeritDelta) + assertEquals(MetricCertainty.EXACT, changes.getValue(2).totalMeritDeltaCertainty) + } + + @Test + fun loginOnlyChangesAreExportedButNotListedAsCounterChanges() { + val older = snapshot("2026-07-20T00:00:00Z", member(1, "Login", 100, 500, 0, lastLogin = 1_000)) + val newer = snapshot("2026-07-21T00:00:00Z", member(1, "Login", 100, 500, 0, lastLogin = 2_000)) + + val result = SnapshotComparison.compare(older, newer, zone) + + assertEquals(true, result.changes.single().lastLoginChanged) + assertEquals(emptyList(), result.counterChanges) + assertEquals(true, SnapshotComparisonCsv.format(result).contains(",CHANGED,1,Login,0,0,0,true,")) + } + private fun snapshot(time: String, vararg members: SnapshotMember) = PlatoonSnapshot(0, Instant.parse(time), members.toList()) - private fun member(uid: Long, name: String, weekly: Long, total: Long, score: Long) = - SnapshotMember(uid, name, 60, weekly, total, 0, score, 0) + private fun member(uid: Long, name: String, weekly: Long, total: Long, score: Long, lastLogin: Long = 0) = + SnapshotMember(uid, name, 60, weekly, total, 0, score, lastLogin) } From a15e619d494b9324aea8bf75b19d0730bc90e48b Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:08:01 +0900 Subject: [PATCH 32/98] Make the roster export importable without changing names The spreadsheet prefix is now reversible: text that is formula-like after its leading apostrophes gets one more, and a user-selected roster import removes exactly one, checking the name limit afterwards. An import whose names changed this way is retained in the lossless form, so later rebuilds from retained files read the real names. Retained files are never unescaped. A roster import also ignores one leading UTF-8 byte order mark, as spreadsheet "CSV UTF-8" saves write. Findings: sec-protocol-2, q-input-3, q-input-4 --- .../app/management/PlatoonCsvImportStore.kt | 21 ++++-- .../management/PlatoonCsvImportStoreTest.kt | 37 ++++++++++ docs/ARCHITECTURE.md | 4 +- docs/PLATOON_MANAGEMENT.md | 6 ++ .../main/java/dev/gf2log/protocol/CsvCell.kt | 16 ++++- .../dev/gf2log/protocol/GuildMembersCsv.kt | 21 ++++-- .../java/dev/gf2log/protocol/CsvCellTest.kt | 13 ++++ .../gf2log/protocol/GuildMembersCsvTest.kt | 68 +++++++++++++++++++ 8 files changed, 175 insertions(+), 11 deletions(-) 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 a03fefb..0f74216 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt @@ -22,8 +22,9 @@ class PlatoonCsvImportStore( 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 + * Reads a bounded UTF-8 roster CSV, validates its complete schema, and hashes the bytes it + * retains. Does not create files, allowing callers to preview a whole selection before + * confirmation. Returns * an immutable prepared import containing only validated data and its stable identity. */ fun prepare(input: InputStream): PreparedImport { @@ -33,15 +34,25 @@ class PlatoonCsvImportStore( .onUnmappableCharacter(CodingErrorAction.REPORT) .decode(ByteBuffer.wrap(bytes)) .toString() - val parsed = requireNotNull(GuildMembersCsv.parse(text)) { "Invalid Platoon CSV" } + // The file may be this app's own spreadsheet export, whose formula-safe name prefix is + // removed here. Retained files are always lossless, so a file whose names changed is + // retained in that form instead of as the selected bytes. + val parsed = requireNotNull(GuildMembersCsv.parse(text, unescapeSpreadsheet = true)) { + "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() + val encoded = if (GuildMembersCsv.parse(text) == parsed) { + bytes + } else { + GuildMembersCsv.format(parsed).toByteArray(StandardCharsets.UTF_8) + } + val hash = encoded.sha256().copyOf(HASH_BYTES).toHex() return PreparedImport( - encoded = bytes, + encoded = encoded, snapshot = parsed, capturedAt = capturedAt, fileName = "import-" + FILE_TIME.format(capturedAt) + "-" + hash + ".csv", 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 dbd48fd..320ed94 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonCsvImportStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonCsvImportStoreTest.kt @@ -1,5 +1,7 @@ package dev.gf2log.app.management +import dev.gf2log.protocol.GuildMembersCsv +import dev.gf2log.protocol.model.GuildMember import java.io.ByteArrayInputStream import java.time.Instant import org.junit.Assert.assertEquals @@ -43,6 +45,41 @@ class PlatoonCsvImportStoreTest { } + @Test + fun spreadsheetExportImportsWithItsNamesAndIsRetainedLossless() { + val snapshot = GuildMembersCsv.Snapshot( + logTime = "2026-07-19T19:29:33Z", + members = listOf( + GuildMember(1u, "-Kai-", 60u, 1u, 2u, 3u, 4u, 5u), + GuildMember(2u, "'=x", 60u, 1u, 2u, 3u, 4u, 5u), + GuildMember(3u, "Plain", 60u, 1u, 2u, 3u, 4u, 5u), + ), + ) + val store = PlatoonCsvImportStore(temporary.newFolder("spreadsheet")) + val exported = GuildMembersCsv.formatForSpreadsheet(snapshot).toByteArray() + + val prepared = store.prepare(ByteArrayInputStream(exported)) + val retained = store.retain(prepared) + + assertEquals(snapshot, prepared.snapshot) + assertEquals(GuildMembersCsv.format(snapshot), retained.file.readText()) + assertEquals(snapshot, GuildMembersCsv.parse(retained.file.readText())) + } + + @Test + fun aCsvSavedWithAByteOrderMarkImportsAndKeepsItsBytes() { + val store = PlatoonCsvImportStore(temporary.newFolder("bom")) + val bytes = ("" + VALID_CSV).toByteArray() + + val retained = store.retain(ByteArrayInputStream(bytes)) + + assertTrue(retained.file.readBytes().contentEquals(bytes)) + assertEquals( + GuildMembersCsv.parse(VALID_CSV), + GuildMembersCsv.parse(retained.file.readText()), + ) + } + @Test(expected = IllegalArgumentException::class) fun malformedCsvIsRejectedBeforeAnythingIsRetained() { val store = PlatoonCsvImportStore(temporary.newFolder("invalid")) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a700d47..c3ded17 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -168,7 +168,9 @@ CSV retained as internal roster evidence preserves protocol text exactly. Explicit spreadsheet-facing roster, member, comparison, and weekly exports pass string cells through a shared encoder that quotes delimiters and prefixes formula-like values with an apostrophe. Numeric counters remain numeric, and -the protection is never fed back into retained member identity. +the protection is never fed back into retained member identity: the prefix is +reversible, a selected roster import removes exactly one, and a file whose names +changed that way is retained in the lossless form. The single worker is deliberate: these eight responses are sparse, and avoiding a worker pool reduces scheduling, memory, and ordering complexity. If diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 56a5d7f..b7fe6e2 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -275,6 +275,12 @@ CSV files created for spreadsheet use neutralize string cells beginning with `=`, `+`, `-`, or `@` (including after leading whitespace), preventing a member name or note from executing as a spreadsheet formula. Internal retained roster evidence remains lossless and is not rewritten with that export-only prefix. +Text that is formula-like after its leading apostrophes also gets one, so the +prefix is reversible: importing the app's own roster export removes exactly one +apostrophe and restores every name, and such an import is retained in the +lossless form. The one residue is a lossless file whose real name is an +apostrophe followed by a formula character, which loses that apostrophe on +import. A roster import ignores one leading UTF-8 byte order mark. The same neutralization is applied to captured roster, activity, Updates, and formation packet tables before they can be copied or opened in spreadsheet software. diff --git a/protocol/src/main/java/dev/gf2log/protocol/CsvCell.kt b/protocol/src/main/java/dev/gf2log/protocol/CsvCell.kt index 07ae15b..11e85dd 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/CsvCell.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/CsvCell.kt @@ -6,6 +6,8 @@ object CsvCell { // Description: // - Applies RFC-style CSV quoting for delimiters, quotes, and line breaks. // - Prefixes spreadsheet-dangerous text with an apostrophe for explicit user exports. + // - Also prefixes text that only becomes formula-like once leading apostrophes are removed, + // so [unescapeSpreadsheet] can undo exactly one prefix and the export stays reversible. // Parameters: // - value: Cell value to encode; null becomes an empty cell. // - spreadsheetSafe: Whether formula-like text must be neutralized for spreadsheet opening. @@ -14,7 +16,8 @@ object CsvCell { fun escape(value: Any?, spreadsheetSafe: Boolean = true): String { val text = value?.toString().orEmpty() val safeText = if ( - spreadsheetSafe && value is CharSequence && canStartSpreadsheetFormula(text) + spreadsheetSafe && value is CharSequence && + canStartSpreadsheetFormula(text.trimStart('\'')) ) { "'$text" } else { @@ -24,6 +27,17 @@ object CsvCell { return "\"${safeText.replace("\"", "\"\"")}\"" } + /** + * Inverts the apostrophe [escape] adds to decoded cell text: removes exactly one leading + * apostrophe when formula-like text follows the apostrophes, and leaves anything else alone. + */ + internal fun unescapeSpreadsheet(text: String): String = + if (text.startsWith('\'') && canStartSpreadsheetFormula(text.trimStart('\''))) { + text.substring(1) + } else { + text + } + private fun canStartSpreadsheetFormula(value: String): Boolean { val firstMeaningful = value.firstOrNull { !it.isWhitespace() } ?: return false return firstMeaningful == '=' || firstMeaningful == '+' || diff --git a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt index d83ff31..db6e454 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt @@ -30,6 +30,12 @@ object GuildMembersCsv { logTime, ).joinToString(",") { CsvCell.escape(it) } + /** Formats [snapshot] in the lossless retained form that every roster reader accepts. */ + fun format(snapshot: Snapshot): String = buildString { + appendLine(HEADER) + snapshot.members.forEach { member -> appendLine(row(member, snapshot.logTime)) } + } + // Function Name: formatForSpreadsheet // Description: // - Formats a parsed roster for explicit export without allowing names to execute formulas. @@ -58,14 +64,19 @@ object GuildMembersCsv { // Description: // - Parses a complete roster snapshot while preserving member names exactly. // - Tolerates surrounding whitespace in scalar fields introduced by spreadsheet copies. + // - Ignores one leading UTF-8 byte order mark, as spreadsheet "CSV UTF-8" saves write one. // Parameters: // - content: CSV text containing one roster snapshot. + // - unescapeSpreadsheet: Whether the file may be a [formatForSpreadsheet] export, whose name + // prefix is removed. Only user-selected files use it; retained files are always lossless. + // A real name made of an apostrophe and a formula character loses that apostrophe. // Returns: // - Returns the parsed snapshot when the schema and rows are valid. // - Returns null when any required field is malformed or capture times disagree. - fun parse(content: String): Snapshot? { + fun parse(content: String, unescapeSpreadsheet: Boolean = false): Snapshot? { if (content.length > MAX_CONTENT_CHARS) return null - val records = CsvRecords.parse(content, COLUMN_COUNT, MAX_RECORDS, MAX_FIELD_CHARS) + val csv = content.removePrefix(BYTE_ORDER_MARK) + val records = CsvRecords.parse(csv, COLUMN_COUNT, MAX_RECORDS, MAX_FIELD_CHARS) ?: return null val header = records.firstOrNull() ?: return null if (header != HEADER.split(',')) return null @@ -81,10 +92,11 @@ object GuildMembersCsv { if (rowLogTime != logTime) return null val uid = row[0].trim().toUIntOrNull() ?: return null if (!seenUids.add(uid)) return null - if (row[1].length > MAX_MEMBER_NAME_CHARS) return null + val name = if (unescapeSpreadsheet) CsvCell.unescapeSpreadsheet(row[1]) else row[1] + if (name.length > MAX_MEMBER_NAME_CHARS) return null GuildMember( uid = uid, - name = row[1], + name = name, level = row[2].trim().toUIntOrNull() ?: return null, weeklyMerit = parseOptionalCounter(row[3]) ?: return null, totalMerit = parseOptionalCounter(row[4]) ?: return null, @@ -121,6 +133,7 @@ object GuildMembersCsv { const val MAX_MEMBER_NAME_CHARS = 256 /** The number of columns in [HEADER]. */ const val COLUMN_COUNT = 9 + private const val BYTE_ORDER_MARK = "\uFEFF" private const val MAX_RECORDS = MAX_ROSTER_MEMBERS + 2 private const val MAX_FIELD_CHARS = 512 } diff --git a/protocol/src/test/java/dev/gf2log/protocol/CsvCellTest.kt b/protocol/src/test/java/dev/gf2log/protocol/CsvCellTest.kt index db2a549..2aa75f6 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/CsvCellTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/CsvCellTest.kt @@ -26,4 +26,17 @@ class CsvCellTest { fun internalCsvCanPreserveFormulaLikeTextWithoutNeutralization() { assertEquals("=literal-name", CsvCell.escape("=literal-name", spreadsheetSafe = false)) } + + @Test + fun spreadsheetPrefixIsReversibleForEveryText() { + listOf( + "=x", "'=x", "''=x", " -y", "' @z", "'abc", "abc", "", "'", " '=x", "\r+1", + ).forEach { value -> + val escaped = CsvCell.escape(value).removeSurrounding("\"").replace("\"\"", "\"") + + assertEquals(value, CsvCell.unescapeSpreadsheet(escaped)) + } + assertEquals("''=x", CsvCell.escape("'=x")) + assertEquals("'abc", CsvCell.escape("'abc")) + } } diff --git a/protocol/src/test/java/dev/gf2log/protocol/GuildMembersCsvTest.kt b/protocol/src/test/java/dev/gf2log/protocol/GuildMembersCsvTest.kt index 39c81bf..4cc584d 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/GuildMembersCsvTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/GuildMembersCsvTest.kt @@ -2,6 +2,7 @@ package dev.gf2log.protocol import dev.gf2log.protocol.model.GuildMember import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Test @@ -131,4 +132,71 @@ class GuildMembersCsvTest { assertNull(GuildMembersCsv.parse(content)) } + + @Test + fun spreadsheetExportRoundTripsThroughAnUnescapingImport() { + val names = listOf( + "-Kai-", + "=x", + "'=x", + "''@y", + " +y", + "\t@z", + "'abc", + "' -", + "plain", + "-".repeat(GuildMembersCsv.MAX_MEMBER_NAME_CHARS), + ) + val snapshot = GuildMembersCsv.Snapshot( + logTime = "2026-01-01T00:00:00Z", + members = names.mapIndexed { index, name -> + GuildMember((index + 1).toUInt(), name, 60u, 1u, 2u, 3u, 4u, 5u) + }, + ) + + val exported = GuildMembersCsv.formatForSpreadsheet(snapshot) + + assertEquals(snapshot, GuildMembersCsv.parse(exported, unescapeSpreadsheet = true)) + assertEquals(snapshot, GuildMembersCsv.parse(GuildMembersCsv.format(snapshot))) + } + + @Test + fun retainedRosterNamesAreNeverUnescaped() { + val content = listOf( + GuildMembersCsv.HEADER, + "1,'-Kai-,60,1,2,3,4,5,2026-01-01T00:00:00Z", + ).joinToString("\n") + + assertEquals("'-Kai-", GuildMembersCsv.parse(content)?.members?.single()?.name) + assertEquals( + "-Kai-", + GuildMembersCsv.parse(content, unescapeSpreadsheet = true)?.members?.single()?.name, + ) + } + + @Test + fun aLeadingByteOrderMarkIsIgnoredButNotOneElsewhere() { + val content = listOf( + GuildMembersCsv.HEADER, + "1,Member,60,1,2,3,4,5,2026-01-01T00:00:00Z", + ).joinToString("\r\n") + + assertEquals(GuildMembersCsv.parse(content), GuildMembersCsv.parse("\uFEFF" + content)) + assertNull(GuildMembersCsv.parse("\uFEFF\uFEFF" + content)) + val inner = GuildMembersCsv.parse(content.replace(",Member,", ",\uFEFFMember,")) + assertEquals("\uFEFFMember", inner?.members?.single()?.name) + } + + @Test + fun namesWithCarriageReturnsRoundTripThroughRetainedRows() { + val snapshot = GuildMembersCsv.Snapshot( + logTime = "2026-01-01T00:00:00Z", + members = listOf( + GuildMember(1u, "a\rb", 60u, 1u, 2u, 3u, 4u, 5u), + GuildMember(2u, "a\r\nb", 60u, 1u, 2u, 3u, 4u, 5u), + ), + ) + + assertEquals(snapshot, GuildMembersCsv.parse(GuildMembersCsv.format(snapshot))) + } } From 2a742fee74aecc7183892c2350f865d9bf8363bd Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:08:09 +0900 Subject: [PATCH 33/98] Never store a roster member with UID 0 UID 0 is the protobuf default of a missing UID field. A roster CSV row with it no longer parses, isValidRoster rejects it, the capture writer drops such a member instead of failing the whole roster, and snapshot ingestion refuses one as a last guard, so the bogus member can no longer break the weekly table or be impossible to delete. Findings: q-data-5, data-sqlite-corruption-poison-2 --- .../PlatoonDatabaseIntegrationTest.kt | 17 +++++++++++++++++ .../gf2log/app/capture/GuildMembersCsvWriter.kt | 3 ++- .../gf2log/app/management/SnapshotIngestion.kt | 3 +++ .../app/capture/GuildMembersCsvWriterTest.kt | 17 +++++++++++++++++ docs/PLATOON_MANAGEMENT.md | 4 +++- .../java/dev/gf2log/protocol/GuildMembersCsv.kt | 4 +++- .../dev/gf2log/protocol/GuildMembersCsvTest.kt | 14 ++++++++++++++ 7 files changed, 59 insertions(+), 3 deletions(-) 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 d64e26e..fbaeead 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -1420,6 +1420,23 @@ class PlatoonDatabaseIntegrationTest { assertEquals(listOf(override), database.listWeeklyOverrides(periodStart.toEpochDay())) } + @Test + fun snapshotWithUidZeroIsRejectedBeforeAnythingIsStored() { + assertThrows(IllegalArgumentException::class.java) { + database.ingestSnapshot( + PlatoonSnapshot( + id = 0, + capturedAt = Instant.parse("2026-08-10T00:00:00Z"), + sourceFile = "gf2log_platoonmembers_20260810T000000Z.csv", + members = listOf(member(0, "Ghost"), member(TARGET_UID, "Target")), + ), + EvidenceSource.SNAPSHOT, + ) + } + + assertTrue(database.listMemberStatuses().isEmpty()) + } + @Test fun unknownJoinOfAnUpdatesWithdrawalPeriodStaysEditable() { val withdrewAt = Instant.parse("2026-08-10T12:00:00Z") 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 e354e07..ec86c36 100644 --- a/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt +++ b/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt @@ -35,7 +35,8 @@ class GuildMembersCsvWriter( val data = payload.data as? GuildMembersData ?: return null if (payload.messageId == 0 || !payload.isEndOfMessage) return null val members = linkedMapOf() - data.members.forEach { member -> members[member.uid] = member } + // UID 0 is the protobuf default of a member whose UID field is missing: no identity. + data.members.forEach { member -> if (member.uid != 0u) members[member.uid] = member } if (members.isEmpty()) return null // Bounded after deduplication: merged continuation frames may repeat a member. val roster = members.values.toList() diff --git a/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt index e2b4c55..c791e90 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt @@ -29,6 +29,9 @@ internal object SnapshotIngestion { require(snapshot.members.all { it.name.length <= GuildMembersCsv.MAX_MEMBER_NAME_CHARS }) { "A Platoon snapshot contains an oversized member name" } + require(snapshot.members.all { it.uid > 0 }) { + "A Platoon snapshot contains an invalid member identity" + } require(snapshot.members.map(SnapshotMember::uid).distinct().size == snapshot.members.size) { "A Platoon snapshot cannot contain duplicate UIDs" } 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 ed1bf52..8e6f6db 100644 --- a/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt @@ -97,6 +97,23 @@ class GuildMembersCsvWriterTest { assertEquals("Current", parsed.members.single().name) } + @Test + fun membersWithoutAUidAreDroppedInsteadOfFailingTheRoster() { + val output = temporaryFolder.newFolder("uid-zero") + val completed = mutableListOf() + val writer = GuildMembersCsvWriter(output, onBatchClosed = completed::add) + + val saved = writer.accept(payload(42, end = true, member(0u, "No UID"), member(7u, "Member"))) + + assertEquals(1, saved?.rowCount) + assertEquals(listOf(7u), completed.single().members.map { it.uid }) + assertEquals( + listOf("Member"), + GuildMembersCsv.parse(saved!!.file.readText(Charsets.UTF_8))?.members?.map { it.name }, + ) + assertNull(writer.accept(payload(messageId = 43, uid = 0u, name = "Only no UID", end = true))) + } + @Test fun workingFileIsCompleteAndSyncedBeforeItIsPublished() { val output = temporaryFolder.newFolder("synced-publication") diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index b7fe6e2..65f1832 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -280,7 +280,9 @@ prefix is reversible: importing the app's own roster export removes exactly one apostrophe and restores every name, and such an import is retained in the lossless form. The one residue is a lossless file whose real name is an apostrophe followed by a formula character, which loses that apostrophe on -import. A roster import ignores one leading UTF-8 byte order mark. +import. A roster import ignores one leading UTF-8 byte order mark, and a row +with UID 0, the protobuf default of a missing UID, is rejected; captured +rosters drop such a member instead. The same neutralization is applied to captured roster, activity, Updates, and formation packet tables before they can be copied or opened in spreadsheet software. diff --git a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt index db6e454..7edd3f1 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt @@ -55,9 +55,11 @@ object GuildMembersCsv { members.size <= MAX_ROSTER_MEMBERS && members.all { it.name.length <= MAX_MEMBER_NAME_CHARS } + /** UID 0 is the protobuf default of an omitted field, never a real member identity. */ fun isValidRoster(members: List): Boolean = members.isNotEmpty() && hasValidMemberBounds(members) && + members.none { it.uid == 0u } && members.map(GuildMember::uid).distinct().size == members.size // Function Name: parse @@ -90,7 +92,7 @@ object GuildMembersCsv { val rowLogTime = row[8].trim().takeIf(String::isNotBlank) ?: return null if (logTime == null) logTime = rowLogTime if (rowLogTime != logTime) return null - val uid = row[0].trim().toUIntOrNull() ?: return null + val uid = row[0].trim().toUIntOrNull()?.takeIf { it != 0u } ?: return null if (!seenUids.add(uid)) return null val name = if (unescapeSpreadsheet) CsvCell.unescapeSpreadsheet(row[1]) else row[1] if (name.length > MAX_MEMBER_NAME_CHARS) return null diff --git a/protocol/src/test/java/dev/gf2log/protocol/GuildMembersCsvTest.kt b/protocol/src/test/java/dev/gf2log/protocol/GuildMembersCsvTest.kt index 4cc584d..99a335d 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/GuildMembersCsvTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/GuildMembersCsvTest.kt @@ -187,6 +187,20 @@ class GuildMembersCsvTest { assertEquals("\uFEFFMember", inner?.members?.single()?.name) } + @Test + fun uidZeroIsNeverAValidRosterMember() { + val content = """ + ${GuildMembersCsv.HEADER} + 0,Ghost,60,1,2,3,4,5,2026-01-01T00:00:00Z + 1,Member,60,1,2,3,4,5,2026-01-01T00:00:00Z + """.trimIndent() + + assertNull(GuildMembersCsv.parse(content)) + assertFalse( + GuildMembersCsv.isValidRoster(listOf(GuildMember(0u, "Ghost", 1u, 0u, 0u, 0u, 0u, 0u))), + ) + } + @Test fun namesWithCarriageReturnsRoundTripThroughRetainedRows() { val snapshot = GuildMembersCsv.Snapshot( From 265e33709a67ae70f27df1d339c80bb5a2283522 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:08:40 +0900 Subject: [PATCH 34/98] Keep open drafts when the Platoon changes or a screen is recreated A newly confirmed Platoon no longer silently drops weekly edits, member edits or cutlines: the screen stays bound to the draft's own storage and asks to save or discard first. The withdrawn-member and membership period dialogs reopen with their typed values after a theme, language or window size change or process death. Findings: q-uiux-3, q-uiux-7 --- .../gf2log/app/DialogDraftIntegrationTest.kt | 71 +++++++++++++++++++ .../main/java/dev/gf2log/app/BaseActivity.kt | 39 ++++++++++ .../dev/gf2log/app/DateTimePickerInput.kt | 42 +++++++++-- .../dev/gf2log/app/MemberDetailActivity.kt | 44 +++++++++--- .../dev/gf2log/app/MembershipBoundaryDraft.kt | 10 +++ .../java/dev/gf2log/app/PlatoonActivity.kt | 14 ++-- .../dev/gf2log/app/WeeklyReportActivity.kt | 16 ++++- .../dev/gf2log/app/WeeklySettingsActivity.kt | 9 ++- .../dev/gf2log/app/WithdrawnMemberDialog.kt | 20 ++++-- .../main/java/dev/gf2log/app/ui/Dialogs.kt | 71 +++++++++++++++++++ app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/ids.xml | 5 ++ app/src/main/res/values/strings.xml | 1 + .../app/MembershipBoundaryDraftRestoreTest.kt | 58 +++++++++++++++ 14 files changed, 373 insertions(+), 28 deletions(-) create mode 100644 app/src/androidTest/java/dev/gf2log/app/DialogDraftIntegrationTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/MembershipBoundaryDraftRestoreTest.kt diff --git a/app/src/androidTest/java/dev/gf2log/app/DialogDraftIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/DialogDraftIntegrationTest.kt new file mode 100644 index 0000000..8c15362 --- /dev/null +++ b/app/src/androidTest/java/dev/gf2log/app/DialogDraftIntegrationTest.kt @@ -0,0 +1,71 @@ +package dev.gf2log.app + +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.util.SparseArray +import android.widget.EditText +import android.widget.LinearLayout +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.gf2log.app.ui.DialogDraft +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DialogDraftIntegrationTest { + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun typedFieldsAndPickedDatesSurviveAParcelledRoundTrip() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val zone = ZoneId.of("Asia/Seoul") + val original = form(zone) + original.note.setText("Left for another Platoon") + val joined = original.joined + joined.selectDate(LocalDate.of(2026, 11, 6)) + val views = SparseArray().also(original.root::saveHierarchyState) + val draft = DialogDraft("withdrawn_member", android.os.Bundle(), views) + + val restored = DialogDraft.fromBundle(parcelled(draft.toBundle())) + assertNotNull(restored) + val recreated = form(zone) + recreated.root.restoreHierarchyState(restored!!.views) + + assertEquals("Left for another Platoon", recreated.note.text.toString()) + assertEquals(LocalDate.of(2026, 11, 6), recreated.joined.boundary?.date) + assertNull(recreated.left.boundary) + } + } + + private class Form(val root: LinearLayout, val note: EditText, val joined: DateTimePickerInput, val left: DateTimePickerInput) + + private fun form(zone: ZoneId): Form { + val note = EditText(context).apply { id = R.id.dialog_note_input } + val joined = DateTimePickerInput(context, "Joined", zone = zone, stateId = R.id.dialog_joined_input) + val left = DateTimePickerInput(context, "Left", zone = zone, stateId = R.id.dialog_left_input) + val root = LinearLayout(context).apply { + addView(note) + addView(joined) + addView(left) + } + return Form(root, note, joined, left) + } + + private fun parcelled(bundle: android.os.Bundle): android.os.Bundle { + val parcel = Parcel.obtain() + try { + parcel.writeBundle(bundle) + parcel.setDataPosition(0) + return requireNotNull(parcel.readBundle(javaClass.classLoader)) + } finally { + parcel.recycle() + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/BaseActivity.kt b/app/src/main/java/dev/gf2log/app/BaseActivity.kt index 195ab9d..5153751 100644 --- a/app/src/main/java/dev/gf2log/app/BaseActivity.kt +++ b/app/src/main/java/dev/gf2log/app/BaseActivity.kt @@ -31,8 +31,23 @@ abstract class BaseActivity : ComponentActivity() { statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), ) super.onCreate(savedInstanceState) + dialogDrafts += dev.gf2log.app.ui.Dialogs.readDrafts(savedInstanceState) } + private val dialogDrafts = mutableListOf() + + override fun onSaveInstanceState(outState: Bundle) { + dev.gf2log.app.ui.Dialogs.saveDrafts(this, outState, dialogDrafts) + super.onSaveInstanceState(outState) + } + + /** + * The input dialog named [key] that was open when the previous instance went away, for the + * screen to reopen once it has what the dialog needs; null when there is none. + */ + internal fun takeDialogDraft(key: String): dev.gf2log.app.ui.DialogDraft? = + dialogDrafts.firstOrNull { it.key == key }?.also(dialogDrafts::remove) + /** Allows a purpose-built surface to use a stable presentation theme. */ protected open fun preferredTheme(context: Context): String = ThemePreferences.get(context) @@ -82,6 +97,30 @@ abstract class BaseActivity : ComponentActivity() { /** Called from onResume only when this instance remains current. */ protected open fun onScreenResumed() = Unit + private var draftPrompt: java.lang.ref.WeakReference? = null + + /** + * For a screen whose active Platoon changed while it holds an unsaved draft. The screen stays + * bound to the draft's own storage, so [save] still writes where the draft came from, and the + * user chooses to save or discard before it follows the new Platoon. Asked once while showing, + * not again on every resume. + */ + protected fun confirmDraftBeforeScopeChange( + @androidx.annotation.StringRes saveLabel: Int, + save: () -> Unit, + discard: () -> Unit, + ) { + if (draftPrompt?.get()?.isShowing == true) return + val dialog = dev.gf2log.app.ui.Dialogs.show( + dev.gf2log.app.ui.Dialogs.builder(this) + .setTitle(R.string.unsaved_changes_title) + .setMessage(R.string.platoon_changed_unsaved_message) + .setNegativeButton(R.string.discard) { _, _ -> discard() } + .setPositiveButton(saveLabel) { _, _ -> save() }, + ) + draftPrompt = java.lang.ref.WeakReference(dialog) + } + companion object { @Volatile private var foreground = java.lang.ref.WeakReference(null) diff --git a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt index 418a215..763612e 100644 --- a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt +++ b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt @@ -4,10 +4,13 @@ import android.app.DatePickerDialog import android.app.TimePickerDialog import android.content.Context import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.os.Parcelable import android.view.Gravity import android.view.View import android.widget.LinearLayout import android.widget.TextView +import androidx.core.os.BundleCompat import dev.gf2log.app.management.MembershipBoundaryValue import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Labels @@ -40,6 +43,8 @@ internal class DateTimePickerInput( private val dateRequired: Boolean = false, private val editable: Boolean = true, private val zone: ZoneId = ZoneId.systemDefault(), + /** A stable id makes the picked value survive recreation of a restorable dialog. */ + stateId: Int = View.NO_ID, ) : LinearLayout(context) { private val selection = MembershipBoundaryDraft( initialValue = initialValue, @@ -59,6 +64,7 @@ internal class DateTimePickerInput( } init { + if (stateId != View.NO_ID) id = stateId orientation = VERTICAL addView(context.text(label, TextRole.LABEL) { setPadding(context.dp(4), 0, context.dp(4), context.dp(4)) @@ -115,10 +121,7 @@ internal class DateTimePickerInput( Dialogs.showTracked( DatePickerDialog( context, - { _, year, month, day -> - selection.selectDate(LocalDate.of(year, month + 1, day)) - renderValue() - }, + { _, year, month, day -> selectDate(LocalDate.of(year, month + 1, day)) }, seed.year, seed.monthValue - 1, seed.dayOfMonth, @@ -126,6 +129,11 @@ internal class DateTimePickerInput( ) } + internal fun selectDate(value: LocalDate) { + selection.selectDate(value) + renderValue() + } + private fun showTimePicker() { val seed = selection.time ?: LocalTime.now(zone).withSecond(0).withNano(0) Dialogs.showTracked( @@ -142,6 +150,27 @@ internal class DateTimePickerInput( ) } + override fun onSaveInstanceState(): Parcelable = Bundle().apply { + putParcelable(STATE_SUPER, super.onSaveInstanceState()) + // Only a changed value is saved: an untouched one is rebuilt exactly from its original. + if (selection.changed) { + putBoolean(STATE_CHANGED, true) + selection.date?.let { putLong(STATE_DATE, it.toEpochDay()) } + selection.time?.let { putInt(STATE_TIME, it.toSecondOfDay()) } + } + } + + override fun onRestoreInstanceState(state: Parcelable?) { + val saved = state as? Bundle ?: return super.onRestoreInstanceState(state) + super.onRestoreInstanceState(BundleCompat.getParcelable(saved, STATE_SUPER, Parcelable::class.java)) + if (!saved.getBoolean(STATE_CHANGED)) return + selection.restoreChanged( + date = saved.takeIf { it.containsKey(STATE_DATE) }?.getLong(STATE_DATE)?.let(LocalDate::ofEpochDay), + time = saved.takeIf { it.containsKey(STATE_TIME) }?.getInt(STATE_TIME)?.toLong()?.let(LocalTime::ofSecondOfDay), + ) + renderValue() + } + private fun renderValue() { val date = selection.date val time = selection.time @@ -164,5 +193,10 @@ internal class DateTimePickerInput( } } +private const val STATE_SUPER = "picker.super" +private const val STATE_CHANGED = "picker.changed" +private const val STATE_DATE = "picker.date" +private const val STATE_TIME = "picker.time" + /** Large text stacks the date and time fields so neither picked value is clipped. */ internal fun stacksPickerFields(fontScale: Float): Boolean = fontScale >= 1.3f diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index b20a6af..e6d7711 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -19,6 +19,7 @@ import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.ui.onTextChanged import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.DialogDraft import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.LabeledField @@ -91,8 +92,12 @@ class MemberDetailActivity : BaseActivity() { override fun onScreenResumed() { if (!profileBinding.isSameStorage(this)) { - // The member belongs to the previous Platoon scope. - finish() + // The member belongs to the previous Platoon scope; unsaved edits are not dropped silently. + if (::saveButton.isInitialized && saveButton.isEnabled) { + confirmDraftBeforeScopeChange(R.string.save_member, { saveProfile(thenFinish = true) }, ::finish) + } else { + finish() + } return } load() @@ -157,6 +162,7 @@ class MemberDetailActivity : BaseActivity() { inputsInitialized = true } render(loaded) + reopenMembershipDialog(loaded.status) } } @@ -273,7 +279,16 @@ class MemberDetailActivity : BaseActivity() { } } - private fun editMembershipPeriod(status: MemberStatus, period: MembershipPeriod) { + /** A membership editor that was open before recreation comes back with what was typed. */ + private fun reopenMembershipDialog(status: MemberStatus) { + takeDialogDraft(ADD_PERIOD_DIALOG)?.let(::addMembershipPeriod) + takeDialogDraft(EDIT_PERIOD_DIALOG)?.let { draft -> + val period = status.membershipPeriods.firstOrNull { it.id == draft.arguments.getLong(ARG_PERIOD_ID) } + period?.let { editMembershipPeriod(status, it, draft) } + } + } + + private fun editMembershipPeriod(status: MemberStatus, period: MembershipPeriod, restored: DialogDraft? = null) { val zone = model?.zone ?: return val joined = DateTimePickerInput( context = this, @@ -284,6 +299,7 @@ class MemberDetailActivity : BaseActivity() { dateRequired = true, editable = period.isJoinedBoundaryEditable, zone = zone, + stateId = R.id.dialog_joined_input, ) val left = DateTimePickerInput( context = this, @@ -293,8 +309,9 @@ class MemberDetailActivity : BaseActivity() { initialTimeKnown = period.leftTimeKnown ?: (period.leftAt != null), editable = period.isLeftBoundaryEditable, zone = zone, + stateId = R.id.dialog_left_input, ) - val noteInput = textField(getString(R.string.membership_note_hint), singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS).apply { setText(period.note) } + val noteInput = textField(getString(R.string.membership_note_hint), id = R.id.dialog_note_input, singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS).apply { setText(period.note) } val error = errorLine() val canDelete = status.membershipPeriods.size > 1 val body = Dialogs.content(this) { @@ -310,7 +327,8 @@ class MemberDetailActivity : BaseActivity() { .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.save_member, null) if (canDelete) builder.setNeutralButton(R.string.delete, null) - Dialogs.show(builder) { dialog -> + val arguments = Bundle().apply { putLong(ARG_PERIOD_ID, period.id) } + Dialogs.showRestorable(builder, EDIT_PERIOD_DIALOG, body, arguments, restored) { dialog -> dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.apply { setTextColor(getColor(R.color.destructive_action)) setOnClickListener { confirmMembershipPeriodDeletion(dialog, period) } @@ -361,11 +379,11 @@ class MemberDetailActivity : BaseActivity() { } } - private fun addMembershipPeriod() { + private fun addMembershipPeriod(restored: DialogDraft? = null) { val zone = model?.zone ?: return - val joined = DateTimePickerInput(this, getString(R.string.join_label), dateRequired = true, zone = zone) - val withdrew = DateTimePickerInput(this, getString(R.string.withdraw_label), zone = zone) - val noteInput = textField(getString(R.string.membership_note_hint), singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS) + val joined = DateTimePickerInput(this, getString(R.string.join_label), dateRequired = true, zone = zone, stateId = R.id.dialog_joined_input) + val withdrew = DateTimePickerInput(this, getString(R.string.withdraw_label), zone = zone, stateId = R.id.dialog_left_input) + val noteInput = textField(getString(R.string.membership_note_hint), id = R.id.dialog_note_input, singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS) val error = errorLine() val body = Dialogs.content(this) { addView(joined, lp()) @@ -373,12 +391,15 @@ class MemberDetailActivity : BaseActivity() { addView(LabeledField(context, getString(R.string.membership_note_hint), noteInput), lp(top = 12)) addView(error, lp(top = 8)) } - Dialogs.show( + Dialogs.showRestorable( Dialogs.builder(this) .setTitle(R.string.add_membership_history) .setView(body) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.add, null), + key = ADD_PERIOD_DIALOG, + content = body, + restored = restored, ) { dialog -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> val joinedBoundary = joined.boundary @@ -437,5 +458,8 @@ class MemberDetailActivity : BaseActivity() { private const val SAVE = "save" private const val DELETE = "delete" private const val STATE_INPUTS_INITIALIZED = "member.inputs_initialized" + private const val ADD_PERIOD_DIALOG = "membership.add" + private const val EDIT_PERIOD_DIALOG = "membership.edit" + private const val ARG_PERIOD_ID = "period_id" } } diff --git a/app/src/main/java/dev/gf2log/app/MembershipBoundaryDraft.kt b/app/src/main/java/dev/gf2log/app/MembershipBoundaryDraft.kt index b54aa38..787e0af 100644 --- a/app/src/main/java/dev/gf2log/app/MembershipBoundaryDraft.kt +++ b/app/src/main/java/dev/gf2log/app/MembershipBoundaryDraft.kt @@ -73,6 +73,16 @@ internal class MembershipBoundaryDraft( dirty = true } + /** Whether the user changed the value; an unchanged draft keeps the original exactly. */ + val changed: Boolean get() = dirty + + /** Reapplies a changed selection saved across recreation; a time needs a date. */ + fun restoreChanged(date: LocalDate?, time: LocalTime?) { + this.date = date + this.time = time?.takeIf { date != null } + dirty = true + } + fun clearTime() { if (time != null) { time = null diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index 27c17d4..7b87be2 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -28,6 +28,7 @@ import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.BarAction import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.DialogDraft import dev.gf2log.app.ui.DocumentAccess import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels @@ -136,6 +137,7 @@ class PlatoonActivity : BaseActivity() { setPadding(dp(16), 0, dp(16), dp(24)) }) setSelecting(selecting) + takeDialogDraft(WithdrawnMemberDialog.KEY)?.let(::addWithdrawnMember) } override fun onScreenResumed() { @@ -353,11 +355,7 @@ class PlatoonActivity : BaseActivity() { }), overflow = listOf( MenuEntry(getString(R.string.select_members)) { setSelecting(true) }, - MenuEntry(getString(R.string.add_withdrawn_member)) { - WithdrawnMemberDialog.show(this, work, repository, { uid -> - model?.statuses?.any { it.uid == uid } == true - }, ::load) - }, + MenuEntry(getString(R.string.add_withdrawn_member)) { addWithdrawnMember() }, MenuEntry(getString(R.string.edit_member_order)) { startActivity(Intent(this, MemberOrderActivity::class.java)) }, @@ -367,6 +365,12 @@ class PlatoonActivity : BaseActivity() { memberAdapter.notifyItemRangeChanged(0, memberAdapter.itemCount) } + private fun addWithdrawnMember(restored: DialogDraft? = null) { + WithdrawnMemberDialog.show(this, work, repository, { uid -> + model?.statuses?.any { it.uid == uid } == true + }, ::load, restored) + } + private fun toggle(uid: Long) { if (!selected.add(uid)) selected.remove(uid) setSelecting(true) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index c5c1c2d..ff5039f 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -123,12 +123,19 @@ class WeeklyReportActivity : BaseActivity() { override fun onScreenResumed() { if (!profileBinding.isCurrent(this)) { - recreate() + // Edits belong to the bound Platoon; they are saved or discarded before the switch. + if (!profileBinding.isSameStorage(this) && hasUnsavedEdits()) { + confirmDraftBeforeScopeChange(R.string.save_weekly_edits, ::saveEdits, ::finishEditing) + } else { + recreate() + } return } requestRender(reconcileRetainedCsv = true) } + private fun hasUnsavedEdits(): Boolean = edits.period != null && !edits.isEmpty() + /** 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() @@ -534,6 +541,11 @@ class WeeklyReportActivity : BaseActivity() { private fun finishEditing() { edits.finish() + // A Platoon selected while editing is followed once the draft is gone. + if (!profileBinding.isCurrent(this)) { + recreate() + return + } model?.let(::render) ?: updateTopBar() } @@ -575,7 +587,7 @@ class WeeklyReportActivity : BaseActivity() { if (result.isSuccess) { if (edits.isEditing(report.periodStart)) edits.finish() Feedback.show(this, R.string.weekly_edits_saved) - requestRender() + if (profileBinding.isCurrent(this)) requestRender() else recreate() } else { Feedback.show(this, R.string.save_failed) } diff --git a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt index 5f95c3a..97c090c 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt @@ -76,7 +76,14 @@ class WeeklySettingsActivity : BaseActivity() { } override fun onScreenResumed() { - if (!profileBinding.isSameStorage(this)) finish() + if (profileBinding.isSameStorage(this)) return + // These cutlines belong to the previous Platoon; unsaved changes are not dropped silently. + val values = readValues() + if (values != null && current(values) == initial) { + finish() + } else { + confirmDraftBeforeScopeChange(R.string.save_settings, ::save, ::finish) + } } private fun LinearLayout.threshold(id: Int, label: Int, value: Long?, maximum: Int?) { diff --git a/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt b/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt index 4f23d9b..3ab7ac4 100644 --- a/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt +++ b/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt @@ -8,6 +8,7 @@ import android.view.View import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.DialogDraft import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.LabeledField @@ -18,22 +19,26 @@ import dev.gf2log.app.ui.textField /** Records a member who left before capture began, with a required join and withdrawal date. */ internal object WithdrawnMemberDialog { + /** Names this dialog's draft for [BaseActivity.takeDialogDraft]. */ + const val KEY = "withdrawn_member" + fun show( activity: Activity, work: BackgroundWork, repository: PlatoonRepository, isKnownUid: (Long) -> Boolean, onAdded: () -> Unit, + restored: DialogDraft? = null, ) = with(activity) { val zone = repository.gameZone() - val uid = LabeledField(this, getString(R.string.uid), textField(getString(R.string.uid_hint), inputType = InputType.TYPE_CLASS_NUMBER)) - val nickname = LabeledField(this, getString(R.string.member_nickname), textField(getString(R.string.member_nickname), maxLength = ManualTextPolicy.MAX_MEMBER_NAME_CHARS)) - val joined = DateTimePickerInput(this, getString(R.string.join_label), dateRequired = true, zone = zone) - val withdrew = DateTimePickerInput(this, getString(R.string.withdraw_label), dateRequired = true, zone = zone) + val uid = LabeledField(this, getString(R.string.uid), textField(getString(R.string.uid_hint), id = R.id.dialog_uid_input, inputType = InputType.TYPE_CLASS_NUMBER)) + val nickname = LabeledField(this, getString(R.string.member_nickname), textField(getString(R.string.member_nickname), id = R.id.dialog_nickname_input, maxLength = ManualTextPolicy.MAX_MEMBER_NAME_CHARS)) + val joined = DateTimePickerInput(this, getString(R.string.join_label), dateRequired = true, zone = zone, stateId = R.id.dialog_joined_input) + val withdrew = DateTimePickerInput(this, getString(R.string.withdraw_label), dateRequired = true, zone = zone, stateId = R.id.dialog_left_input) val note = LabeledField( this, getString(R.string.membership_note_hint), - textField(getString(R.string.membership_note_hint), singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS), + textField(getString(R.string.membership_note_hint), id = R.id.dialog_note_input, singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS), ) val error = errorLine() val content = Dialogs.content(this) { @@ -41,12 +46,15 @@ internal object WithdrawnMemberDialog { addView(view, lp(top = if (index == 0) 0 else 12)) } } - Dialogs.show( + Dialogs.showRestorable( Dialogs.builder(this) .setTitle(R.string.add_withdrawn_member) .setView(content) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.add, null), + key = KEY, + content = content, + restored = restored, ) { dialog -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> val uidValue = uid.input.text.toString().trim().toLongOrNull()?.takeIf { it > 0 } 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 57547ec..ddb61b7 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt @@ -2,6 +2,9 @@ package dev.gf2log.app.ui import android.app.AlertDialog import android.content.Context +import android.os.Bundle +import android.os.Parcelable +import android.util.SparseArray import android.view.View import android.widget.LinearLayout import android.widget.ScrollView @@ -71,6 +74,48 @@ internal object Dialogs { open.remove(owner)?.forEach { it.get()?.takeIf(android.app.Dialog::isShowing)?.dismiss() } } + private class Restorable(val key: String, val arguments: Bundle, val content: View) + + private val restorable = java.util.WeakHashMap() + + /** + * Shows an input dialog that its screen reopens, with the values typed so far, after a theme, + * language, or window-size change or process death. [key] names the dialog within its screen, + * [arguments] hold what rebuilding it needs, and [content] is the view set on [builder]; its + * fields need stable view ids. A screen passes the [DialogDraft] it took back as [restored]. + */ + fun showRestorable( + builder: AlertDialog.Builder, + key: String, + content: View, + arguments: Bundle = Bundle(), + restored: DialogDraft? = null, + destructive: Boolean = false, + onShown: (AlertDialog) -> Unit = {}, + ): AlertDialog { + restored?.let { content.restoreHierarchyState(it.views) } + return show(builder, destructive, onShown).also { restorable[it] = Restorable(key, arguments, content) } + } + + /** Saves the open restorable dialogs of [owner], plus [pending] drafts not reopened yet. */ + fun saveDrafts(owner: android.app.Activity, outState: Bundle, pending: List) { + val drafts = ArrayList() + pending.mapTo(drafts, DialogDraft::toBundle) + open[owner]?.mapNotNull { it.get()?.takeIf(android.app.Dialog::isShowing) }?.forEach { dialog -> + val shown = restorable[dialog] ?: return@forEach + val views = SparseArray().also(shown.content::saveHierarchyState) + drafts += DialogDraft(shown.key, shown.arguments, views).toBundle() + } + if (drafts.isNotEmpty()) outState.putParcelableArrayList(STATE_DRAFTS, drafts) + } + + fun readDrafts(state: Bundle?): List = state + ?.let { androidx.core.os.BundleCompat.getParcelableArrayList(it, STATE_DRAFTS, Bundle::class.java) } + .orEmpty() + .mapNotNull(DialogDraft::fromBundle) + + private const val STATE_DRAFTS = "dialogs.drafts" + fun confirm( context: Context, title: CharSequence, @@ -135,6 +180,32 @@ internal object Dialogs { } } +/** An input dialog's typed values, kept across recreation of its screen; see [Dialogs.showRestorable]. */ +internal class DialogDraft( + val key: String, + val arguments: Bundle, + internal val views: SparseArray, +) { + internal fun toBundle(): Bundle = Bundle().apply { + putString(KEY, key) + putBundle(ARGUMENTS, arguments) + putSparseParcelableArray(VIEWS, views) + } + + internal companion object { + private const val KEY = "key" + private const val ARGUMENTS = "arguments" + private const val VIEWS = "views" + + fun fromBundle(bundle: Bundle): DialogDraft? { + val key = bundle.getString(KEY) ?: return null + val views = androidx.core.os.BundleCompat.getSparseParcelableArray(bundle, VIEWS, Parcelable::class.java) + ?: return null + return DialogDraft(key, bundle.getBundle(ARGUMENTS) ?: Bundle(), views) + } + } +} + /** * Replaces the visible transient message instead of extending Android's Toast queue, so rapid * repeated actions keep their feedback current. diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index cf8838c..9c02247 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -498,6 +498,7 @@ 합계는 일별 칸에서 계산됩니다. 나가기 전에 변경 내용을 저장할까요? 저장하지 않은 변경 + 다른 서클이 선택되었습니다. 이 변경 사항을 이전 서클에 먼저 저장할까요? %1$s – %2$s 초기화 05:00 · %1$s 서버 시간 · %1$s diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 3ca9c5a..193fb2c 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -8,6 +8,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad66e9a..09cd9ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -504,6 +504,7 @@ Totals are calculated from the daily cells. Save your changes before leaving? Unsaved changes + Another Platoon is now selected. Save these changes to the previous Platoon first? %1$s – %2$s Reset 05:00 · %1$s Server time · %1$s diff --git a/app/src/test/java/dev/gf2log/app/MembershipBoundaryDraftRestoreTest.kt b/app/src/test/java/dev/gf2log/app/MembershipBoundaryDraftRestoreTest.kt new file mode 100644 index 0000000..60b0cc3 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/MembershipBoundaryDraftRestoreTest.kt @@ -0,0 +1,58 @@ +package dev.gf2log.app + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MembershipBoundaryDraftRestoreTest { + private val zone = ZoneId.of("Asia/Seoul") + + @Test + fun `an untouched value is not saved and stays exact`() { + val instant = Instant.parse("2026-07-29T10:14:45.123456789Z") + val draft = MembershipBoundaryDraft(instant, LocalDate.of(2026, 7, 29), true, zone) + + assertFalse(draft.changed) + assertEquals(instant, draft.boundary?.instant) + } + + @Test + fun `a picked value restored into a fresh draft gives the same boundary`() { + val typed = MembershipBoundaryDraft(null, null, false, zone).apply { + selectDate(LocalDate.of(2026, 11, 6)) + selectTime(LocalTime.of(12, 34)) + } + val recreated = MembershipBoundaryDraft(null, null, false, zone) + + recreated.restoreChanged(typed.date, typed.time) + + assertTrue(recreated.changed) + assertEquals(typed.boundary, recreated.boundary) + } + + @Test + fun `a cleared original stays cleared after recreation`() { + val original = Instant.parse("2026-07-29T10:14:45Z") + val recreated = MembershipBoundaryDraft(original, null, true, zone) + + recreated.restoreChanged(date = null, time = null) + + assertNull(recreated.boundary) + assertNull(recreated.date) + } + + @Test + fun `a time without a date is not restored`() { + val recreated = MembershipBoundaryDraft(null, null, false, zone) + + recreated.restoreChanged(date = null, time = LocalTime.NOON) + + assertNull(recreated.time) + } +} From 7c3dcbe4a545421cb3989358607f91234779d129 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:08:53 +0900 Subject: [PATCH 35/98] Document draft, export and announcement contracts in the UI design system --- docs/UI_DESIGN_SYSTEM.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/UI_DESIGN_SYSTEM.md b/docs/UI_DESIGN_SYSTEM.md index bd3070a..4c34056 100644 --- a/docs/UI_DESIGN_SYSTEM.md +++ b/docs/UI_DESIGN_SYSTEM.md @@ -33,8 +33,8 @@ one-off drawables. | `Controls` | Button roles, checkbox/switch tints, and the segmented rail. | | `Views` | Text roles, labeled fields, grouped navigation rows, switch rows, panels, badges. | | `StateViews` | One loading, empty, and failure composition for every screen. | -| `Dialogs` / `Feedback` | Styled dialogs with a red treatment for destructive confirmations; a single replacing toast. | -| `BackgroundWork` | Serialized off-main work bound to the screen lifecycle; keyed requests supersede stale ones. | +| `Dialogs` / `Feedback` | Styled dialogs with a red treatment for destructive confirmations; restorable input dialogs; a single replacing toast. | +| `BackgroundWork` | Serialized off-main work bound to the screen lifecycle; keyed requests supersede stale ones, and `always` effects run even after recreation. | | `SingleViewAdapter` | Hosts headers, footers, and state views inside one `RecyclerView` without recycling them. | ## Control roles @@ -65,13 +65,25 @@ when Android animations are disabled. bar becomes a contextual bar (close, count, actions) and Back exits selection. - Edit modes (the weekly table) replace the top bar with close and save actions, highlight changed cells, and confirm before discarding unsaved changes. +- A screen holding an unsaved draft (weekly edits, member details, cutlines) + stays bound to the draft's Platoon when another Platoon becomes active, and + asks to save or discard before it follows the new one. - Every repository, file, and network call runs through `BackgroundWork`; the screen shows a loading, empty, or failure state instead of blocking input. + Feedback and cleanup that do not need the screen go in `always`, so a task + that finishes after recreation still reports its outcome once. - Text fields carry stable view IDs and Activities save transient choices, so rotation, theme or language changes, and large-screen resizing keep drafts. + Input dialogs shown with `Dialogs.showRestorable` reopen with their typed + values; the Discord webhook dialog deliberately does not, because its token + must never enter saved instance state. - Document pickers use the Activity Result API. Exports regenerate their content from the repository when the destination returns rather than holding it in - memory across process death. + memory across process death. `DocumentAccess.writeExport` deletes the created + document when the write fails or the provider is refused, and a refused + provider names the supported locations. +- Times shown or edited in a Platoon's game reset zone carry a "Server time" + caption whenever that zone's offset differs from the device's. ## Layout and accessibility @@ -96,8 +108,12 @@ when Android animations are disabled. - Text wraps when it carries meaning; only short button labels may ellipsize. - Icon-only controls require an accessible name and show it as a tooltip. Decorative icons beside text are hidden from the accessibility tree. -- Composite rows expose one button with a combined description; validation - errors and live status use polite accessibility live regions. +- Composite rows expose one button with a combined description; a row without + an action is focusable information, never a button. Validation errors, live + status, a changed top-bar title, and every loading, empty, or failure state use + polite live regions; a state view sets its text just after it is attached so + the change is announced. Re-applied top-bar actions keep their buttons, so + screen-reader focus survives in-place updates such as Select all. - Pressed and disabled states are explicit. Destructive operations use a confirmation dialog, and async work disables duplicate submission. - Motion is limited to short transform/opacity transitions and is skipped when From d3073b897e5be73160a42bd2facc9e4d0c407137 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:08:53 +0900 Subject: [PATCH 36/98] Fall back to the live weekly table when the active revision cannot be read Reading the selected revision now happens inside the same failure guard as decoding it, so the pointer is cleared instead of failing every load, and a stored blob outside the payload limit is never loaded into the cursor window. Finding: sec-doc-claims-m1 --- .../PlatoonRepositoryIntegrationTest.kt | 43 +++++++++++++++++++ .../app/management/PlatoonRepository.kt | 17 +++++--- .../app/management/WeeklyHistoryStore.kt | 28 ++++++++++-- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt index 793b081..7167cc7 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt @@ -65,6 +65,49 @@ class PlatoonRepositoryIntegrationTest { assertEquals(oversizedName, revision.report.members.single().name) } + @Test + fun anActiveRevisionBeyondThePayloadLimitFallsBackToTheLiveTable() { + val repository = utcRepository() + val capturedAt = Instant.parse("2026-07-29T12:00:00Z") + repository.ingest(capturedAt, listOf(member(UID, "A", 100u)), "a.csv") + // Only a restored archive can hold such a row: the app never stores one this large. + val oversizedId = PlatoonRepository.withExclusiveDatabase(SCOPE) { + PlatoonDatabase(context, SCOPE.databaseName).use { database -> + val db = database.writableDatabase + val id = db.insertOrThrow( + "weekly_report_history", + null, + ContentValues().apply { + put("period_start", CLOSING_WEEK.toEpochDay()) + put("recorded_at", capturedAt.toEpochMilli()) + put("fingerprint", "0".repeat(64)) + put("report_blob", ByteArray(3 * 1024 * 1024) { 1 }) + }, + ) + db.insertWithOnConflict( + "weekly_report_history_state", + null, + ContentValues().apply { + put("period_start", CLOSING_WEEK.toEpochDay()) + put("active_history_id", id) + }, + android.database.sqlite.SQLiteDatabase.CONFLICT_REPLACE, + ) + id + } + } + + val revision = repository.buildWeeklyTableRevision( + referenceDay = CLOSING_WEEK, + zoneId = ZoneOffset.UTC, + asOf = capturedAt.plusSeconds(3_600), + ) + + assertEquals(listOf(UID), revision.report.members.map { it.uid }) + assertTrue(repository.listWeeklyReportHistory(CLOSING_WEEK).none { it.active }) + assertEquals(null, repository.weeklyReportHistory(oversizedId, CLOSING_WEEK)) + } + @Test fun rosterCapturedExactlyAtTheSundayResetAlsoRefreshesTheWeekItCloses() { val repository = utcRepository() 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 6ab4929..3e02fab 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -339,13 +339,16 @@ internal class PlatoonRepository( return activeRevision(live) ?: live } - /** The selected historical revision, [live] when none is selected, or null if undecodable. */ - private fun activeRevision(live: WeeklyTableRevision): WeeklyTableRevision? { + /** + * The selected historical revision, [live] when none is selected, or null when the selection + * cannot be read or decoded, so the caller falls back to [live]. + */ + private fun activeRevision(live: WeeklyTableRevision): WeeklyTableRevision? = runCatching { val payload = access { it.activeWeeklyReportHistoryPayload(live.report.periodStart.toEpochDay()) } ?: return live - return runCatching { WeeklyReportHistoryCodec.decodeRevision(payload) }.getOrNull() - } + WeeklyReportHistoryCodec.decodeRevision(payload) + }.getOrNull() private fun buildLiveWeeklyRevision( referenceDay: LocalDate, @@ -465,8 +468,10 @@ internal class PlatoonRepository( access { it.listWeeklyReportHistory(periodStart.toEpochDay()) } fun weeklyReportHistory(id: Long, periodStart: LocalDate): WeeklyReportBuilder.Report? = - access { it.weeklyReportHistoryPayload(id, periodStart.toEpochDay()) } - ?.let { payload -> runCatching { WeeklyReportHistoryCodec.decode(payload) }.getOrNull() } + runCatching { + access { it.weeklyReportHistoryPayload(id, periodStart.toEpochDay()) } + ?.let(WeeklyReportHistoryCodec::decode) + }.getOrNull() fun restoreWeeklyReportHistory(id: Long, periodStart: LocalDate): Boolean = access { it.activateWeeklyReportHistory(id, periodStart.toEpochDay()) } 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 a56cf48..94220cd 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.management import android.content.ContentValues +import android.database.Cursor import android.database.sqlite.SQLiteDatabase import dev.gf2log.app.management.PlatoonDatabase.Companion.MAX_WEEKLY_REPORT_HISTORY import java.time.Instant @@ -92,19 +93,30 @@ internal object WeeklyHistoryStore { } } + /** + * The stored revision, null when absent, or an empty [INVALID_PAYLOAD] when its blob is outside + * the payload limit, which is never loaded into the cursor window. + */ fun payload(db: SQLiteDatabase, id: Long, periodStartEpochDay: Long): ByteArray? = db.rawQuery( - "SELECT report_blob FROM weekly_report_history WHERE id = ? AND period_start = ?", + "SELECT $BOUNDED_PAYLOAD FROM weekly_report_history WHERE id = ? AND period_start = ?", arrayOf(id.toString(), periodStartEpochDay.toString()), - ).use { cursor -> if (cursor.moveToFirst()) cursor.getBlob(0) else null } + ).use(::boundedPayload) + /** The selected revision, null when none is selected, or [INVALID_PAYLOAD] as in [payload]. */ fun activePayload(db: SQLiteDatabase, periodStartEpochDay: Long): ByteArray? = db.rawQuery( - "SELECT history.report_blob FROM weekly_report_history_state state " + + "SELECT $BOUNDED_PAYLOAD FROM weekly_report_history_state state " + "JOIN weekly_report_history history ON history.id = state.active_history_id " + "WHERE state.period_start = ? AND history.period_start = state.period_start", arrayOf(periodStartEpochDay.toString()), - ).use { cursor -> if (cursor.moveToFirst()) cursor.getBlob(0) else null } + ).use(::boundedPayload) + + private fun boundedPayload(cursor: Cursor): ByteArray? = when { + !cursor.moveToFirst() -> null + cursor.isNull(0) -> INVALID_PAYLOAD + else -> cursor.getBlob(0) + } fun activate(db: SQLiteDatabase, id: Long, periodStartEpochDay: Long): Boolean { val exists = db.exists( @@ -179,4 +191,12 @@ internal object WeeklyHistoryStore { } private val FINGERPRINT = Regex("[0-9a-f]{64}") + + /** Selects report_blob only within the payload limit, otherwise NULL. */ + private val BOUNDED_PAYLOAD = + "CASE WHEN length(report_blob) BETWEEN 1 AND ${WeeklyReportHistoryCodec.MAX_PAYLOAD_BYTES} " + + "THEN report_blob END" + + /** A payload no codec accepts, standing for a stored blob outside the payload limit. */ + val INVALID_PAYLOAD = ByteArray(0) } From 6e67b2b6609088dfdfb7b90fd660e41f5d096a36 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:10:08 +0900 Subject: [PATCH 37/98] Write the one-time capture region migration asynchronously Findings: input-settings-prefs-3 --- .../dev/gf2log/app/settings/ClientServerRegionPreferences.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt index bd0f224..a0fa01d 100644 --- a/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/ClientServerRegionPreferences.kt @@ -25,8 +25,8 @@ internal class ClientServerRegionPreferences(context: Context) { val legacy = legacySeed(GameTimeZonePreferences.legacyRegion(appContext), packageName) val editor = preferences.edit().putBoolean(legacyMigratedKey(packageName), true) legacy?.let { editor.putString(packageName, it.storedValue) } - // An unwritten migration still answers this call and is retried by the next one. - editor.commit() + // Memory is updated at once; a write that never reaches the disk is retried by the next process. + editor.apply() return legacy } From 14378b6c513e75b34d5c5a74d330271655f701fd Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:11:53 +0900 Subject: [PATCH 38/98] Guard CSV import undo with a digest of primary evidence only The undo seal hashed the whole database file, so opening the Weekly screen after an import recorded a derived history row and refused the undo. The seal and both checks now hash every evidence table in content order, leaving out weekly history, its selection and SQLite bookkeeping. Finding: data-doc-contracts-5 --- .../PlatoonBackupManagerIntegrationTest.kt | 40 ++++++ .../app/management/PlatoonBackupManager.kt | 9 +- .../app/management/PlatoonEvidenceDigest.kt | 123 ++++++++++++++++++ .../management/PlatoonEvidenceDigestTest.kt | 53 ++++++++ docs/ARCHITECTURE.md | 5 +- 5 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/management/PlatoonEvidenceDigest.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/PlatoonEvidenceDigestTest.kt 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 38825a8..3b6e3f3 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -557,6 +557,46 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(CsvImportCheckpointManager(context, storageScope).canUndo()) } + @Test + fun derivedWeeklyHistoryWrittenAfterACsvImportKeepsItsUndo() { + seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") + val import = CsvImportCheckpointManager(context, storageScope) + import.create(emptySet()) + replaceDatabaseForCheckpoint(CURRENT_UID, "After import", "after-import.csv") + import.seal() + + // Opening the Weekly screen on a week without evidence records a derived revision. + val repository = PlatoonRepository(context, storageScope) + val week = LocalDate.of(2026, 10, 4) + repository.buildWeeklyTableRevision(week, java.time.ZoneOffset.UTC, Instant.parse("2026-10-05T00:00:00Z")) + assertTrue(repository.listWeeklyReportHistory(week).isNotEmpty()) + + val undo = CsvImportCheckpointManager(context, storageScope) + assertTrue(undo.canUndo()) + undo.restore() + PlatoonDatabase(context).use { database -> + assertEquals(1L, count(database.readableDatabase, "members", "uid = ?", ARCHIVED_UID)) + } + } + + @Test + fun aMemberEditAfterACsvImportStillRefusesItsUndo() { + seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") + val import = CsvImportCheckpointManager(context, storageScope) + import.create(emptySet()) + replaceDatabaseForCheckpoint(CURRENT_UID, "After import", "after-import.csv") + import.seal() + + assertTrue(PlatoonRepository(context, storageScope).updateMember(CURRENT_UID, "Renamed", "Edited")) + + assertThrows(IllegalStateException::class.java) { + CsvImportCheckpointManager(context, storageScope).restore() + } + PlatoonDatabase(context).use { database -> + assertEquals(1L, count(database.readableDatabase, "members", "uid = ?", CURRENT_UID)) + } + } + @Test fun constructingRepositoriesAndManagersDoesNotWaitForTheExclusiveGate() { val held = java.util.concurrent.CountDownLatch(1) 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 0ca5ba5..dc90347 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -59,11 +59,14 @@ class PlatoonBackupManager internal constructor( } } - /** Returns a stable digest after closing SQLite so WAL state is checkpointed. */ + /** + * Returns a stable digest of the primary evidence after closing SQLite so WAL state is + * checkpointed; derived weekly history does not change it (see [PlatoonEvidenceDigest]). + */ internal fun currentDatabaseSha256(): String = PlatoonRepository.withExclusiveDatabase(storageScope) { ensureDatabaseExists() - databaseFile.sha256Hex() + PlatoonEvidenceDigest.sha256(databaseFile) } fun exportFull(output: OutputStream) { @@ -264,7 +267,7 @@ class PlatoonBackupManager internal constructor( PlatoonRepository.withExclusiveDatabase(storageScope) { if (expectedDatabaseSha256 != null) { ensureDatabaseExists() - check(databaseFile.sha256Hex() == expectedDatabaseSha256) { + check(PlatoonEvidenceDigest.sha256(databaseFile) == expectedDatabaseSha256) { "Platoon data changed after the CSV import; undo would overwrite newer changes" } } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonEvidenceDigest.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonEvidenceDigest.kt new file mode 100644 index 0000000..a3f8fde --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonEvidenceDigest.kt @@ -0,0 +1,123 @@ +package dev.gf2log.app.management + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import java.io.File +import java.nio.ByteBuffer +import java.security.MessageDigest + +/** + * Digests the primary Platoon evidence of a database for the CSV import undo guard. + * + * Every table counts except the derived weekly history, its restored selection, and SQLite or + * Android bookkeeping, so recording or selecting a weekly revision never makes an otherwise valid + * undo look like newer work, while any evidence, membership, note, or manual-value change does. + * Rows are hashed in content order, independent of where SQLite stores them. + */ +internal object PlatoonEvidenceDigest { + /** Derived tables that the undo checkpoint restores as well but that never hold evidence. */ + private val DERIVED_TABLES = setOf("weekly_report_history", "weekly_report_history_state") + + /** Whether [table] holds evidence the digest covers. */ + fun covers(table: String): Boolean = + table !in DERIVED_TABLES && + table != "android_metadata" && + !table.startsWith("sqlite_") + + /** Opens [databaseFile] read-only; the caller holds the exclusive database section. */ + fun sha256(databaseFile: File): String = + SQLiteDatabase.openDatabase(databaseFile.path, null, SQLiteDatabase.OPEN_READONLY).use(::sha256) + + fun sha256(db: SQLiteDatabase): String { + val hasher = Hasher(db.version) + val tables = db.rawQuery( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + null, + ).use { cursor -> + buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } + } + tables.filter(::covers).forEach { table -> + val quoted = "\"" + table.replace("\"", "\"\"") + "\"" + val columns = db.rawQuery("SELECT * FROM $quoted LIMIT 0", null).use { it.columnNames.toList() } + hasher.table(table, columns) + val order = columns.indices.joinToString(", ") { (it + 1).toString() } + db.rawQuery("SELECT * FROM $quoted ORDER BY $order", null).use { cursor -> + while (cursor.moveToNext()) { + hasher.row(List(cursor.columnCount) { index -> cursor.value(index) }) + } + } + } + return hasher.hex() + } + + private fun Cursor.value(index: Int): Any? = when (getType(index)) { + Cursor.FIELD_TYPE_NULL -> null + Cursor.FIELD_TYPE_INTEGER -> getLong(index) + Cursor.FIELD_TYPE_FLOAT -> getDouble(index) + Cursor.FIELD_TYPE_BLOB -> getBlob(index) + else -> getString(index) + } + + /** + * Feeds tables and rows into SHA-256 with a type tag and length for every value, so no two + * different databases share an encoding. + */ + class Hasher(schemaVersion: Int) { + private val digest = MessageDigest.getInstance("SHA-256") + + init { + digest.update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(schemaVersion).array()) + } + + fun table(name: String, columns: List) { + tag(TABLE) + text(name) + digest.update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(columns.size).array()) + columns.forEach(::text) + } + + fun row(values: List) { + tag(ROW) + values.forEach { value -> + when (value) { + null -> tag(NULL) + is Long -> { + tag(INTEGER) + digest.update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(value).array()) + } + is Double -> { + tag(FLOAT) + digest.update(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(value.toRawBits()).array()) + } + is ByteArray -> { + tag(BLOB) + bytes(value) + } + else -> { + tag(TEXT) + text(value.toString()) + } + } + } + } + + fun hex(): String = digest.digest().toHex() + + private fun tag(value: Byte) = digest.update(value) + + private fun text(value: String) = bytes(value.toByteArray(Charsets.UTF_8)) + + private fun bytes(value: ByteArray) { + digest.update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(value.size).array()) + digest.update(value) + } + } + + private const val TABLE: Byte = 1 + private const val ROW: Byte = 2 + private const val NULL: Byte = 3 + private const val INTEGER: Byte = 4 + private const val FLOAT: Byte = 5 + private const val TEXT: Byte = 6 + private const val BLOB: Byte = 7 +} diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonEvidenceDigestTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonEvidenceDigestTest.kt new file mode 100644 index 0000000..d4b847d --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonEvidenceDigestTest.kt @@ -0,0 +1,53 @@ +package dev.gf2log.app.management + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlatoonEvidenceDigestTest { + @Test + fun derivedHistoryAndSqliteBookkeepingAreNotEvidence() { + assertFalse(PlatoonEvidenceDigest.covers("weekly_report_history")) + assertFalse(PlatoonEvidenceDigest.covers("weekly_report_history_state")) + assertFalse(PlatoonEvidenceDigest.covers("sqlite_sequence")) + assertFalse(PlatoonEvidenceDigest.covers("android_metadata")) + listOf( + "snapshots", + "snapshot_members", + "members", + "membership_periods", + "member_events", + "platoon_activity", + "weekly_notes", + "weekly_overrides", + ).forEach { assertTrue(it, PlatoonEvidenceDigest.covers(it)) } + } + + @Test + fun everyValueKeepsItsTypeAndBoundary() { + fun digest(vararg rows: List) = PlatoonEvidenceDigest.Hasher(13).apply { + table("members", listOf("a", "b")) + rows.forEach(::row) + }.hex() + + assertEquals(digest(listOf(1L, "x")), digest(listOf(1L, "x"))) + assertNotEquals(digest(listOf(1L, "x")), digest(listOf("1", "x"))) + assertNotEquals(digest(listOf("ab", "c")), digest(listOf("a", "bc"))) + assertNotEquals(digest(listOf(null, "")), digest(listOf("", null))) + assertNotEquals(digest(listOf(1L, byteArrayOf(1))), digest(listOf(1L, byteArrayOf(1, 0)))) + assertNotEquals(digest(listOf(1L, "x")), digest(listOf(1L, "x"), listOf(1L, "x"))) + } + + @Test + fun theSchemaVersionAndTableNamesArePartOfTheDigest() { + fun digest(version: Int, table: String) = PlatoonEvidenceDigest.Hasher(version).apply { + table(table, listOf("uid")) + row(listOf(7L)) + }.hex() + + assertNotEquals(digest(12, "members"), digest(13, "members")) + assertNotEquals(digest(13, "members"), digest(13, "weekly_notes")) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae40198..24bfecf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -432,8 +432,9 @@ import seals. Undo quarantines only the planned identities and restores the 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; the digest is +A post-import digest of the primary Platoon evidence refuses undo after any +later Platoon mutation so a one-level rollback cannot silently overwrite newer +work, while recording or selecting a derived weekly revision does not; 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, From 2d19ab0b681d2b871db0c6adbe110501c832d8b8 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:15:08 +0900 Subject: [PATCH 39/98] Keep scheduled recording matched to the saved Platoons Choices now name a login (client and server region) instead of embedding the reset zone, so a reset-zone override no longer silently empties the selection; 2.7.1 keys are normalised and Platoons of one login with different zones form one server recorded before its earliest reset. A choice follows its Platoons to a new server region, and a choice whose Platoons are gone is shown as stale in Settings instead of as active. An alarm now re-checks the current Platoons before waking the phone, so a server whose last Platoon was deleted is neither woken for nor reported. A handled reset implausibly far ahead (a clock set forward) is ignored and forgotten, so real nights are not skipped afterwards. The alarm clock show intent opens the exported launcher screen. Findings: q-time-boundaries-1, q-time-boundaries-5, data-deletion-remnants-2, sec-components-1 --- .../dev/gf2log/app/ScheduledRecordingSetup.kt | 4 +- .../main/java/dev/gf2log/app/StandbyFlow.kt | 2 +- .../dev/gf2log/app/StandbyServerDialog.kt | 4 +- .../gf2log/app/standby/ScheduledRecording.kt | 111 +++++++++++++--- .../dev/gf2log/app/standby/StandbyPlan.kt | 80 ++++++++++-- app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../ScheduledRecordingShowIntentTest.kt | 34 +++++ .../gf2log/app/standby/StandbyPlannerTest.kt | 119 +++++++++++++++++- 9 files changed, 319 insertions(+), 37 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/standby/ScheduledRecordingShowIntentTest.kt diff --git a/app/src/main/java/dev/gf2log/app/ScheduledRecordingSetup.kt b/app/src/main/java/dev/gf2log/app/ScheduledRecordingSetup.kt index 291ff02..06cd2e1 100644 --- a/app/src/main/java/dev/gf2log/app/ScheduledRecordingSetup.kt +++ b/app/src/main/java/dev/gf2log/app/ScheduledRecordingSetup.kt @@ -67,6 +67,8 @@ internal class ScheduledRecordingSetup( private fun status(enabled: Boolean, blocked: Boolean): String = when { blocked -> activity.getString(R.string.scheduled_blocked) !enabled -> activity.getString(R.string.scheduled_off) + // The chosen servers lost their Platoons: say so instead of showing them as active. + ScheduledRecording.isSelectionStale(activity) -> activity.getString(R.string.scheduled_stale) else -> { val next = ScheduledRecording.next(activity)?.let(::nextText) ?: activity.getString(R.string.scheduled_nothing) if (ScheduledRecording.canScheduleExactAlarms(activity)) { @@ -83,7 +85,7 @@ internal class ScheduledRecordingSetup( Labels.format(Labels.DATE_TIME, next.startAt, ZoneId.systemDefault()), ) - private fun chosenSummary(): String? = ScheduledRecording.selectedKeys(activity) + private fun chosenSummary(): String? = ScheduledRecording.chosenKeys(activity) .map { key -> GameServerRegion.fromStored(key.split('|').getOrNull(1)) } .sortedBy(GameServerRegion::ordinal) .joinToString(", ") { Labels.region(activity, it) } diff --git a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt index 117a1b2..01eec19 100644 --- a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt +++ b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt @@ -167,7 +167,7 @@ internal class StandbyFlow( } private fun rememberedKeys(): Set = - prefs().getStringSet(KEY_SELECTED, emptySet()).orEmpty() + prefs().getStringSet(KEY_SELECTED, emptySet()).orEmpty().mapTo(HashSet(), StandbyPlanner::selectionKey) private fun remember(chosen: List) { prefs().edit { putStringSet(KEY_SELECTED, chosen.mapTo(HashSet(), StandbyServer::key)) } diff --git a/app/src/main/java/dev/gf2log/app/StandbyServerDialog.kt b/app/src/main/java/dev/gf2log/app/StandbyServerDialog.kt index 67549e3..1783ccf 100644 --- a/app/src/main/java/dev/gf2log/app/StandbyServerDialog.kt +++ b/app/src/main/java/dev/gf2log/app/StandbyServerDialog.kt @@ -55,7 +55,7 @@ internal object StandbyServerDialog { val valid = StandbyPlanner.isValidSelection(checked) val planned = if (valid) StandbyPlanner.plan(checked, now).associate { it.server to it.startAt } else emptyMap() boxes.forEach { (server, box) -> - box.text = label(server, planned[server] ?: StandbyPlanner.nextReset(server.zone, now).minus(StandbyPlanner.LEAD)) + box.text = label(server, planned[server] ?: StandbyPlanner.nextReset(server, now).minus(StandbyPlanner.LEAD)) } dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = valid } @@ -74,7 +74,7 @@ internal object StandbyServerDialog { // Default to the preselected server of this client, if it still exists. val preferred = clientServers.firstOrNull { it.key in preselected } clientServers.forEach { server -> - val start = StandbyPlanner.nextReset(server.zone, now).minus(StandbyPlanner.LEAD) + val start = StandbyPlanner.nextReset(server, now).minus(StandbyPlanner.LEAD) val box = activity.checkBox(label(server, start), server == preferred) { checked -> // A client logs in to one server at a time: checking one clears its siblings. if (checked) { diff --git a/app/src/main/java/dev/gf2log/app/standby/ScheduledRecording.kt b/app/src/main/java/dev/gf2log/app/standby/ScheduledRecording.kt index f389f9a..67604e0 100644 --- a/app/src/main/java/dev/gf2log/app/standby/ScheduledRecording.kt +++ b/app/src/main/java/dev/gf2log/app/standby/ScheduledRecording.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.standby +import android.app.Activity import android.app.AlarmManager import android.app.KeyguardManager import android.app.PendingIntent @@ -12,7 +13,7 @@ import android.os.PowerManager import android.util.Log import androidx.annotation.WorkerThread import androidx.core.content.edit -import dev.gf2log.app.OptionsActivity +import dev.gf2log.app.MainActivity import dev.gf2log.app.StandbyActivity import dev.gf2log.app.capture.MainThread import dev.gf2log.app.settings.GameServerRegion @@ -27,9 +28,9 @@ import java.util.concurrent.Executors * on, gets past a swipe lock screen, runs the same recording as standby mode, and turns the screen * off again. Every day, with no standby screen kept on overnight. * - * Only the on/off state and the chosen server keys are stored. Every wake-up is planned again from - * the current Platoons, and the next one is set before the current one runs, so one failed night - * never breaks the chain. + * Only the on/off state, the chosen server keys, and the Platoons they were chosen for are stored. + * Every wake-up is planned again from the current Platoons, and the next one is set before the + * current one runs, so one failed night never breaks the chain. */ internal object ScheduledRecording { @@ -42,9 +43,31 @@ internal object ScheduledRecording { private val worker = Executors.newSingleThreadExecutor { Thread(it, "GF2Scheduled") } + /** Orders a new choice against the worker's rewrite of the stored one, so neither is lost. */ + private val selectionLock = Any() + + /** + * The screen the alarm clock's show intent opens. Android hands that intent to any app that + * asks for the next alarm clock, so it must be the exported launcher screen, with no extras. + */ + internal val alarmShowScreen: Class get() = MainActivity::class.java + fun isEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_ENABLED, false) - fun selectedKeys(context: Context): Set = prefs(context).getStringSet(KEY_SERVERS, emptySet()).orEmpty() + fun selectedKeys(context: Context): Set = storedKeys(context).mapTo(linkedSetOf(), StandbyPlanner::selectionKey) + + /** + * The chosen servers that still had a saved Platoon when the next wake-up was last planned; + * before that first plan, every chosen server. + */ + fun chosenKeys(context: Context): Set = + prefs(context).getStringSet(KEY_MATCHED, null)?.toSet() ?: selectedKeys(context) + + /** On, with servers chosen, none of which has a saved Platoon any more: nothing is recorded. */ + fun isSelectionStale(context: Context): Boolean = + isEnabled(context) && selectedKeys(context).isNotEmpty() && chosenKeys(context).isEmpty() + + private fun storedKeys(context: Context): Set = prefs(context).getStringSet(KEY_SERVERS, emptySet()).orEmpty() /** A PIN, pattern, or password cannot be passed unattended, so it rules scheduling out. */ fun isBlockedByScreenLock(context: Context): Boolean = @@ -67,9 +90,13 @@ internal object ScheduledRecording { /** Turns scheduling on for [chosen]; [done] gets the first wake-up on the main thread. */ fun enable(context: Context, chosen: List, done: (Next?) -> Unit) { - prefs(context).edit(commit = true) { - putBoolean(KEY_ENABLED, true) - putStringSet(KEY_SERVERS, chosen.mapTo(HashSet(), StandbyServer::key)) + synchronized(selectionLock) { + prefs(context).edit(commit = true) { + putBoolean(KEY_ENABLED, true) + putStringSet(KEY_SERVERS, chosen.mapTo(HashSet(), StandbyServer::key)) + putStringSet(KEY_MATCHED, chosen.mapTo(HashSet(), StandbyServer::key)) + putStringSet(KEY_PLATOONS, chosen.flatMapTo(HashSet(), StandbyServer::storageIds)) + } } reschedule(context, done) } @@ -104,9 +131,14 @@ internal object ScheduledRecording { disable(context) return null } - val selected = selectedServers(context, StandbyProfiles.load(context), selectedKeys(context)) + val selected = updateSelection(context, StandbyPlanner.servers(StandbyProfiles.load(context))) val now = Instant.now() - val from = StandbyPlanner.scheduleFrom(now, handledReset(context)) + val handled = handledReset(context) + if (handled != null && StandbyPlanner.trustedHandledReset(now, handled) == null) { + // Stored under a clock set forward: forgotten, so the real next reset is not skipped. + prefs(context).edit(commit = true) { remove(KEY_HANDLED_RESET) } + } + val from = StandbyPlanner.scheduleFrom(now, handled) val rehearsal = rehearsalReset(context, now)?.takeIf { it.isAfter(from) } val runs = when { selected.isEmpty() -> emptyList() @@ -135,16 +167,36 @@ internal object ScheduledRecording { } /** - * The alarm went off. The next wake-up is planned first; then, unless the phone is locked - * with a PIN or in use, the standby screen wakes it and takes over. + * The alarm went off. The next wake-up is planned first; then, for the chosen servers that + * still have a saved Platoon, and unless the phone is locked with a PIN or in use, the standby + * screen wakes it and takes over. [scheduled] runs on the main thread once all that is done. */ fun onAlarm(context: Context, reset: Instant, keys: Set, scheduled: () -> Unit) { // A second delivery for the same reset (two reschedules racing) must not run it twice. - val handled = handledReset(context) + val handled = StandbyPlanner.trustedHandledReset(Instant.now(), handledReset(context)) if (handled != null && !reset.isAfter(handled)) return scheduled() prefs(context).edit(commit = true) { putLong(KEY_HANDLED_RESET, reset.toEpochMilli()) } - reschedule(context) { scheduled() } - if (!isEnabled(context) || keys.isEmpty()) return + val app = context.applicationContext + worker.execute { + // Decided on the state the alarm found, before planning can turn scheduling off. + val enabled = isEnabled(app) + runCatching { scheduleNext(app) }.onFailure { Log.w(TAG, "Could not schedule the next recording", it) } + // A server whose last Platoon was deleted since the alarm was set is neither woken + // for nor reported. + val live = runCatching { StandbyPlanner.selected(StandbyPlanner.servers(StandbyProfiles.load(app)), keys) } + .getOrDefault(emptyList()) + .mapTo(linkedSetOf(), StandbyServer::key) + MainThread.post { + try { + if (enabled && live.isNotEmpty()) wake(app, reset, live) + } finally { + scheduled() + } + } + } + } + + private fun wake(context: Context, reset: Instant, keys: Set) { val outcome = when { // Too late to finish before the day ends (an inexact alarm can come hours late). !Instant.now().isBefore(reset.plus(StandbyPlanner.GRACE).minus(LATEST_START_MARGIN)) -> StandbyOutcome.MISSED @@ -222,7 +274,7 @@ internal object ScheduledRecording { MainThread.post { // The user tapped the screen meanwhile, or a recreated screen armed already. if (activity.isGone() || StandbyAutomation.isArmed) return@post - val servers = selectedServers(app, profiles, keys) + val servers = StandbyPlanner.selected(StandbyPlanner.servers(profiles), keys) if (servers.isEmpty()) return@post activity.standDown() val plan = StandbyPlanner.plan(servers, Instant.now()) { reset } StandbyAutomation.arm(activity, plan, profiles.associate { it.storageId to it.packageName }) @@ -233,9 +285,26 @@ internal object ScheduledRecording { private fun StandbyActivity.isGone(): Boolean = isFinishing || isDestroyed - /** The chosen servers that still exist, one per client. */ - private fun selectedServers(context: Context, profiles: List, keys: Set): List = - StandbyPlanner.servers(profiles).filter { it.key in keys }.distinctBy(StandbyServer::packageName) + /** + * The chosen servers that still exist, one per client. A chosen server whose Platoons moved to + * another server region follows them, and a key stored before 2.7.2 loses its zone; what still + * matches is kept for Settings. + */ + @WorkerThread + private fun updateSelection(context: Context, servers: List): List = + synchronized(selectionLock) { + val prefs = prefs(context) + val stored = storedKeys(context) + val keys = StandbyPlanner.followPlatoons(servers, stored, prefs.getStringSet(KEY_PLATOONS, emptySet()).orEmpty()) + val selected = StandbyPlanner.selected(servers, keys) + prefs.edit(commit = true) { + if (keys != stored) putStringSet(KEY_SERVERS, HashSet(keys)) + putStringSet(KEY_MATCHED, selected.mapTo(HashSet(), StandbyServer::key)) + // Kept while nothing matches, so the choice can still follow its Platoons later. + if (selected.isNotEmpty()) putStringSet(KEY_PLATOONS, selected.flatMapTo(HashSet(), StandbyServer::storageIds)) + } + selected + } /** Screen on or a call in progress: someone is using the phone. */ private fun isInUse(context: Context): Boolean { @@ -261,7 +330,7 @@ internal object ScheduledRecording { val show = PendingIntent.getActivity( context, REQUEST_SHOW, - Intent(context, OptionsActivity::class.java), + Intent(context, alarmShowScreen), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) alarms.setAlarmClock(AlarmManager.AlarmClockInfo(start.toEpochMilli(), show), fire) @@ -313,6 +382,8 @@ internal object ScheduledRecording { private const val PREFS = "scheduled_recording" private const val KEY_ENABLED = "enabled" private const val KEY_SERVERS = "servers" + private const val KEY_MATCHED = "matched_servers" + private const val KEY_PLATOONS = "server_platoons" private const val KEY_HANDLED_RESET = "handled_reset" private const val KEY_NEXT_START = "next_start" private const val KEY_NEXT_REGIONS = "next_regions" diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyPlan.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyPlan.kt index c8d4249..02c813f 100644 --- a/app/src/main/java/dev/gf2log/app/standby/StandbyPlan.kt +++ b/app/src/main/java/dev/gf2log/app/standby/StandbyPlan.kt @@ -8,7 +8,9 @@ import java.time.ZonedDateTime /** * One game server that standby mode can record: a client package logged in to one server region, - * and every saved Platoon on it. Its day ends at 05:00 in [zone]. + * and every saved Platoon on it. Its day ends at 05:00 in [zone], the reset zone most of its + * Platoons use; a per-Platoon override can add others to [zones], and it is then recorded before + * whichever of them resets first. */ internal data class StandbyServer( val packageName: String, @@ -16,9 +18,13 @@ internal data class StandbyServer( val zone: ZoneId, val storageIds: Set, val platoonNames: List, + val zones: Set = setOf(zone), ) { - /** Stable across launches, so the last checkbox choice can be restored. */ - val key: String get() = "$packageName|${region.storedValue}|${zone.id}" + /** + * Names the login (client and server region) only, so a stored choice survives a reset-zone + * override and is restored across launches. + */ + val key: String get() = "$packageName|${region.storedValue}" } /** A saved Platoon as standby planning sees it. */ @@ -52,22 +58,64 @@ internal object StandbyPlanner { private val RESET_HOUR = GameServerRegion.RESET_TIME + /** + * A handled reset is stored as its alarm fires, at most a few [LEAD]s before it. One further + * ahead than this was written under a clock set forward, and is not trusted once it is back. + */ + val MAX_HANDLED_LEAD: Duration = Duration.ofHours(1) + /** * Groups saved Platoons by the server they log in to. Platoons of one client and region share - * a login, so they are recorded together. + * a login, so they are recorded together, even when their reset zones differ. */ fun servers(profiles: List): List = profiles - .groupBy { Triple(it.packageName, it.region, it.zone) } + .groupBy { it.packageName to it.region } .map { (key, members) -> + val zones = members.map(StandbyProfile::zone) StandbyServer( packageName = key.first, region = key.second, - zone = key.third, + // The most common zone; a tie goes to the first Platoon's. + zone = zones.groupingBy { it }.eachCount().maxBy { it.value }.key, storageIds = members.mapTo(linkedSetOf(), StandbyProfile::storageId), platoonNames = members.map(StandbyProfile::platoonName).distinct(), + zones = zones.toCollection(linkedSetOf()), ) } - .sortedWith(compareBy { it.packageName }.thenBy { it.region.ordinal }.thenBy { it.zone.id }) + .sortedWith(compareBy { it.packageName }.thenBy { it.region.ordinal }) + + /** A stored choice as [StandbyServer.key] spells it; keys stored before 2.7.2 also named a zone. */ + fun selectionKey(stored: String): String = stored.split('|').take(2).joinToString("|") + + /** + * The servers among [servers] that [keys] chose, one per client. A key whose server no longer + * has a saved Platoon matches nothing. + */ + fun selected(servers: List, keys: Collection): List { + val wanted = keys.mapTo(HashSet(), ::selectionKey) + return servers.filter { it.key in wanted }.distinctBy(StandbyServer::packageName) + } + + /** + * [keys] after the Platoons they were chosen for ([platoons], storage IDs) moved: a chosen + * server that no longer exists is replaced by the one server of the same client those Platoons + * are on now, unless that client already has a chosen server that exists. Anything else stays, + * so a choice whose Platoons are all gone keeps matching nothing. + */ + fun followPlatoons(servers: List, keys: Set, platoons: Set): Set { + val existing = servers.mapTo(HashSet(), StandbyServer::key) + val result = keys.mapTo(linkedSetOf(), ::selectionKey) + result.filter { it !in existing }.forEach { stale -> + val packageName = stale.substringBefore('|') + if (result.any { it in existing && it.substringBefore('|') == packageName }) return@forEach + val target = servers + .filter { it.packageName == packageName && it.storageIds.any(platoons::contains) } + .singleOrNull() ?: return@forEach + result.remove(stale) + result.add(target.key) + } + return result + } /** * A client logs in to one server at a time and standby cannot switch servers, so at most one @@ -76,6 +124,9 @@ internal object StandbyPlanner { fun isValidSelection(selected: Collection): Boolean = selected.isNotEmpty() && selected.groupBy(StandbyServer::packageName).values.all { it.size == 1 } + /** The earliest next reset among [server]'s zones, so no Platoon on it has its day end unrecorded. */ + fun nextReset(server: StandbyServer, now: Instant): Instant = server.zones.minOf { nextReset(it, now) } + /** The next 05:00 in [zone] that is at least [MIN_NOTICE] after [now]. */ fun nextReset(zone: ZoneId, now: Instant): Instant { val local = now.atZone(zone) @@ -92,7 +143,7 @@ internal object StandbyPlanner { fun plan( selected: Collection, now: Instant, - resetOf: (StandbyServer) -> Instant = { nextReset(it.zone, now) }, + resetOf: (StandbyServer) -> Instant = { nextReset(it, now) }, ): List { require(isValidSelection(selected)) { "One server per client must be selected" } return selected @@ -128,10 +179,17 @@ internal object StandbyPlanner { return runs.filter { it.reset == reset }.sortedBy(StandbyRun::startAt) } + /** [handledReset], or null when it lies [MAX_HANDLED_LEAD] or more after [now]. */ + fun trustedHandledReset(now: Instant, handledReset: Instant?): Instant? = + handledReset?.takeIf { it.isBefore(now.plus(MAX_HANDLED_LEAD)) } + /** * Where scheduling the next wake-up starts from: never before [handledReset], so a reset that - * was already handled (even a run that ended before it) is not scheduled again. + * was already handled (even a run that ended before it) is not scheduled again. A handled + * reset implausibly far ahead suppresses nothing, so real nights follow a corrected clock. */ - fun scheduleFrom(now: Instant, handledReset: Instant?): Instant = - if (handledReset != null && !handledReset.isBefore(now)) handledReset.plusSeconds(1) else now + fun scheduleFrom(now: Instant, handledReset: Instant?): Instant { + val handled = trustedHandledReset(now, handledReset) + return if (handled != null && !handled.isBefore(now)) handled.plusSeconds(1) else now + } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fb3f4ec..4b709ea 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -646,6 +646,7 @@ 꺼짐 다음 기록: %1$s · %2$s 기록할 저장 서버가 없음 + 선택한 서버에 저장된 서클이 더 이상 없어 기록하지 않습니다. 서버를 다시 선택하세요. GF2logger의 ‘알람 및 리마인더’ 권한이 꺼져 있어 기록이 늦게 시작될 수 있습니다. 기록할 서버 선택한 서버마다 매일 게임 하루가 끝나기 5분 전(초기화 시각이 같은 서버가 여럿이면 조금 더 일찍)에 멤버를 기록합니다. 클라이언트는 한 번에 한 서버에만 접속하므로 클라이언트마다 서버를 하나씩 선택하세요. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef8188d..5115187 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -654,6 +654,7 @@ Off Next: %1$s · %2$s No saved server to record + The chosen servers no longer have a saved Platoon, so nothing is recorded. Choose servers again. Alarms & reminders is off for GF2logger, so a recording may start late. Servers to record Each chosen server is recorded every day, five minutes before its game day ends, or a little earlier when several share a reset. A client logs in to one server at a time, so choose one server per client. diff --git a/app/src/test/java/dev/gf2log/app/standby/ScheduledRecordingShowIntentTest.kt b/app/src/test/java/dev/gf2log/app/standby/ScheduledRecordingShowIntentTest.kt new file mode 100644 index 0000000..221a50d --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/standby/ScheduledRecordingShowIntentTest.kt @@ -0,0 +1,34 @@ +package dev.gf2log.app.standby + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.w3c.dom.Element + +/** + * Any app can read the next alarm clock's show intent, so it may only open a screen the manifest + * already exports. + */ +class ScheduledRecordingShowIntentTest { + + @Test + fun `the alarm clock show intent opens an exported screen`() { + val target = ScheduledRecording.alarmShowScreen.name + val activity = manifestActivities().firstOrNull { qualified(it.getAttribute("android:name")) == target } + + assertNotNull("$target is not declared in the manifest", activity) + assertEquals("true", activity!!.getAttribute("android:exported")) + } + + private fun manifestActivities(): List { + // Unit tests run from the module directory. + val manifest = File("src/main/AndroidManifest.xml") + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(manifest) + val nodes = document.getElementsByTagName("activity") + return (0 until nodes.length).map { nodes.item(it) as Element } + } + + private fun qualified(name: String): String = if (name.startsWith(".")) "dev.gf2log.app$name" else name +} diff --git a/app/src/test/java/dev/gf2log/app/standby/StandbyPlannerTest.kt b/app/src/test/java/dev/gf2log/app/standby/StandbyPlannerTest.kt index a503e29..b12af1c 100644 --- a/app/src/test/java/dev/gf2log/app/standby/StandbyPlannerTest.kt +++ b/app/src/test/java/dev/gf2log/app/standby/StandbyPlannerTest.kt @@ -5,6 +5,7 @@ import dev.gf2log.app.settings.GameServerRegion import java.time.Duration import java.time.Instant import java.time.ZoneId +import java.time.ZoneOffset import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -144,6 +145,114 @@ class StandbyPlannerTest { assertNull(StandbyPlanner.finalDeadline(emptyList())) } + @Test + fun `platoons of one login with different reset zones are one server`() { + val servers = StandbyPlanner.servers( + listOf( + profile("a", SupportedGamePackages.HAOPLAY, GameServerRegion.HAOPLAY_KOREA, "Alpha"), + profile("b", SupportedGamePackages.HAOPLAY, GameServerRegion.HAOPLAY_KOREA, "Bravo", ZoneOffset.UTC), + ), + ) + + val server = servers.single() + assertEquals(setOf("a", "b"), server.storageIds) + assertEquals(setOf(ZoneOffset.ofHours(9), ZoneOffset.UTC), server.zones) + assertEquals("${SupportedGamePackages.HAOPLAY}|haoplay_korea", server.key) + assertTrue(StandbyPlanner.isValidSelection(servers)) + } + + @Test + fun `a server with several reset zones is recorded before the first of them`() { + val server = StandbyPlanner.servers( + listOf( + profile("a", SupportedGamePackages.HAOPLAY, GameServerRegion.HAOPLAY_KOREA, "Alpha"), + profile("b", SupportedGamePackages.HAOPLAY, GameServerRegion.HAOPLAY_KOREA, "Bravo", ZoneOffset.UTC), + ), + ).single() + val evening = Instant.parse("2026-10-19T21:00:00Z") // 05:00 UTC comes before 05:00 in Seoul + val morning = Instant.parse("2026-10-20T06:00:00Z") // and 05:00 in Seoul before the next 05:00 UTC + + assertEquals(Instant.parse("2026-10-20T05:00:00Z"), StandbyPlanner.plan(listOf(server), evening).single().reset) + assertEquals(Instant.parse("2026-10-20T20:00:00Z"), StandbyPlanner.plan(listOf(server), morning).single().reset) + } + + @Test + fun `a choice stored before a reset-zone override still matches after it`() { + // 2.7.1 stored the zone in the key; an override to UTC then matched nothing. + val legacy = "${SupportedGamePackages.HAOPLAY}|haoplay_korea|+09:00" + val overridden = StandbyPlanner.servers( + listOf(profile("a", SupportedGamePackages.HAOPLAY, GameServerRegion.HAOPLAY_KOREA, "Alpha", ZoneOffset.UTC)), + ) + + assertEquals("${SupportedGamePackages.HAOPLAY}|haoplay_korea", StandbyPlanner.selectionKey(legacy)) + assertEquals(overridden, StandbyPlanner.selected(overridden, setOf(legacy))) + assertEquals(overridden, StandbyPlanner.selected(overridden, setOf(overridden.single().key))) + } + + @Test + fun `a chosen server without a saved platoon is dropped, and none left means nothing runs`() { + val servers = StandbyPlanner.servers( + listOf(profile("g", SupportedGamePackages.DARKWINTER, GameServerRegion.DARKWINTER_GLOBAL, "Golf")), + ) + val deletedKorea = korea.key + + assertEquals(servers, StandbyPlanner.selected(servers, setOf(deletedKorea, servers.single().key))) + assertTrue(StandbyPlanner.selected(servers, setOf(deletedKorea)).isEmpty()) + assertTrue(StandbyPlanner.selected(emptyList(), setOf(deletedKorea)).isEmpty()) + } + + @Test + fun `a choice follows its platoon to the server region it moved to`() { + val moved = StandbyPlanner.servers( + listOf(profile("a", SupportedGamePackages.HAOPLAY, GameServerRegion.HAOPLAY_JAPAN, "Alpha")), + ) + + assertEquals(setOf(moved.single().key), StandbyPlanner.followPlatoons(moved, setOf(korea.key), setOf("a"))) + // The Platoon is gone: the choice stays, matching nothing. + assertEquals(setOf(korea.key), StandbyPlanner.followPlatoons(moved, setOf(korea.key), setOf("deleted"))) + // A legacy key is normalised whether or not it moves. + assertEquals( + setOf(moved.single().key), + StandbyPlanner.followPlatoons(moved, setOf("${moved.single().key}|+09:00"), emptySet()), + ) + } + + @Test + fun `a choice does not follow when its client already has an existing chosen server`() { + val servers = StandbyPlanner.servers( + listOf( + profile("a", SupportedGamePackages.HAOPLAY, GameServerRegion.HAOPLAY_JAPAN, "Alpha"), + profile("b", SupportedGamePackages.HAOPLAY, GameServerRegion.HAOPLAY_ASIA, "Bravo"), + ), + ) + val asiaKey = servers.single { it.region == GameServerRegion.HAOPLAY_ASIA }.key + + assertEquals( + setOf(korea.key, asiaKey), + StandbyPlanner.followPlatoons(servers, setOf(korea.key, asiaKey), setOf("a", "b")), + ) + } + + @Test + fun `a handled reset far in the future from a forward clock jump is ignored`() { + val now = Instant.parse("2026-10-19T13:05:00Z") // 22:05 in Seoul, clock corrected back + val future = Instant.parse("2026-10-19T20:00:00Z") // handled while the clock was ahead + + assertNull(StandbyPlanner.trustedHandledReset(now, future)) + assertEquals(now, StandbyPlanner.scheduleFrom(now, future)) + val next = StandbyPlanner.firstGroup(StandbyPlanner.plan(listOf(korea), StandbyPlanner.scheduleFrom(now, future))) + assertEquals(future, next.single().reset) + } + + @Test + fun `a handled reset just ahead still guards against a second delivery`() { + val now = Instant.parse("2026-10-19T19:58:00Z") // 04:58 in Seoul + val handled = Instant.parse("2026-10-19T20:00:00Z") + + assertEquals(handled, StandbyPlanner.trustedHandledReset(now, handled)) + assertEquals(handled.plusSeconds(1), StandbyPlanner.scheduleFrom(now, handled)) + } + private fun server(packageName: String, region: GameServerRegion) = StandbyServer( packageName = packageName, region = region, @@ -152,11 +261,17 @@ class StandbyPlannerTest { platoonNames = listOf(region.storedValue), ) - private fun profile(id: String, packageName: String, region: GameServerRegion, name: String) = StandbyProfile( + private fun profile( + id: String, + packageName: String, + region: GameServerRegion, + name: String, + zone: ZoneId = requireNotNull(region.serverZone), + ) = StandbyProfile( storageId = id, packageName = packageName, region = region, - zone = requireNotNull(region.serverZone) as ZoneId, + zone = zone, platoonName = name, ) } From 3eded7b3ba5407c98d5b7a26f0e970723f93fad5 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:15:22 +0900 Subject: [PATCH 40/98] Stop standby taps at any notice that follows a login Taps used to stop only on notices of an accepted Platoon, so a profile that arrived but could not be attributed or kept (an unverified client, a buffer overflow, a failed profile update) left them tapping inside the logged-in game until the two-minute window ended. Every notice that only follows a parsed Platoon profile now stops them, and an unverified client ends the run at once with its own outcome. Finding: sec-doc-claims-5 --- .../main/java/dev/gf2log/app/StandbyFlow.kt | 1 + .../gf2log/app/standby/StandbyAutomation.kt | 28 +++---- .../dev/gf2log/app/standby/StandbyResults.kt | 3 + .../gf2log/app/standby/StandbyTapPolicy.kt | 50 ++++++++++++ app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../app/standby/StandbyTapPolicyTest.kt | 81 +++++++++++++++++++ 7 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/standby/StandbyTapPolicy.kt create mode 100644 app/src/test/java/dev/gf2log/app/standby/StandbyTapPolicyTest.kt diff --git a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt index 01eec19..dca6ab8 100644 --- a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt +++ b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt @@ -179,6 +179,7 @@ internal class StandbyFlow( StandbyOutcome.RECORDED -> R.string.standby_outcome_recorded StandbyOutcome.OTHER_PLATOON -> R.string.standby_outcome_other_platoon StandbyOutcome.TIMED_OUT -> R.string.standby_outcome_timed_out + StandbyOutcome.CLIENT_UNVERIFIED -> R.string.standby_outcome_client_unverified StandbyOutcome.MISSED -> R.string.standby_outcome_missed StandbyOutcome.NO_ACCESSIBILITY -> R.string.standby_outcome_no_accessibility StandbyOutcome.NO_VPN_CONSENT -> R.string.standby_outcome_no_vpn 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 f6050e3..18dd931 100644 --- a/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt +++ b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt @@ -11,7 +11,6 @@ import android.os.Handler import android.os.Looper import android.util.Log import dev.gf2log.app.StandbyActivity -import dev.gf2log.app.capture.CaptureNotice import dev.gf2log.app.capture.CapturePhase import dev.gf2log.app.capture.CaptureSnapshot import dev.gf2log.app.capture.CaptureStatus @@ -162,9 +161,9 @@ internal object StandbyAutomation { 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. - now >= nextTapAt && now < tapsUntil && !loggedIn -> { + // Taps repeat at the title screen's Start position until the first capture notice + // that only a logged-in game produces, and never after the window. + StandbyTapPolicy.tapDue(now, nextTapAt, tapsUntil, loggedIn) -> { nextTapAt = now + TAP_INTERVAL_MS val (x, y) = startButton(runs[index].server.packageName) StandbyAutomationService.instance?.tapIfFront(runs[index].server.packageName, x, y) @@ -311,13 +310,16 @@ internal object StandbyAutomation { enter(Phase.IN_GAME) } - /** Platoon notices only follow a login, so the title screen is gone once one arrives. */ + /** + * Platoon notices only follow a login, so the title screen is gone once one arrives, even one + * saying the data could not be used. + */ 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 - } + if (phase != Phase.IN_GAME || notice.atMillis < phaseStartedAt) return + if (StandbyTapPolicy.stopsTaps(notice.notice)) loggedIn = true + StandbyTapPolicy.endsRun(notice.notice)?.let(::endRun) } private fun onRosterPersisted(storageId: String) { @@ -483,14 +485,4 @@ internal object StandbyAutomation { private const val CLOSE_GAME_DELAY_MS = 1_500L private const val START_X = 0.5f private const val START_Y = 0.85f - private val LOGIN_NOTICES = setOf( - CaptureNotice.PLATOON_DETECTED, - CaptureNotice.PENDING_DETECTED, - CaptureNotice.PLATOON_CONFIRMED, - CaptureNotice.IDENTITY_CHANGED, - CaptureNotice.PENDING_IDENTITY_CHANGED, - CaptureNotice.ROSTER_SAVED, - CaptureNotice.ROSTER_UPDATED, - CaptureNotice.ROSTER_RECOVERED, - ) } diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyResults.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyResults.kt index f7a62d9..d3ee2f6 100644 --- a/app/src/main/java/dev/gf2log/app/standby/StandbyResults.kt +++ b/app/src/main/java/dev/gf2log/app/standby/StandbyResults.kt @@ -17,6 +17,9 @@ internal enum class StandbyOutcome { /** No roster arrived before the run's time ran out. */ TIMED_OUT, + /** The game logged in, but capture could not verify which client sent its data. */ + CLIENT_UNVERIFIED, + /** The run never started because an earlier one or the deadline left no time. */ MISSED, diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyTapPolicy.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyTapPolicy.kt new file mode 100644 index 0000000..66b1eca --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/standby/StandbyTapPolicy.kt @@ -0,0 +1,50 @@ +package dev.gf2log.app.standby + +import dev.gf2log.app.capture.CaptureNotice + +/** + * When standby's blind taps at the title screen's Start position stop, decided from capture + * notices alone; no Android dependencies. + * + * Every notice here follows a parsed Platoon profile, accepted or rejected, and the game sends one + * only after login. Once any of them arrives the title screen is gone, so a further tap could land + * inside the game. + */ +internal object StandbyTapPolicy { + + /** Notices that only a logged-in game produces, whether or not its data could be used. */ + val LOGGED_IN_NOTICES: Set = setOf( + CaptureNotice.PLATOON_DETECTED, + CaptureNotice.PENDING_DETECTED, + CaptureNotice.PLATOON_CONFIRMED, + CaptureNotice.IDENTITY_CHANGED, + CaptureNotice.PENDING_IDENTITY_CHANGED, + CaptureNotice.ROSTER_SAVED, + CaptureNotice.ROSTER_UPDATED, + CaptureNotice.ROSTER_RECOVERED, + // The profile arrived but could not be attributed or kept. + CaptureNotice.CLIENT_UNVERIFIED, + CaptureNotice.IDENTIFIED_FLOW_BUFFER_OVERFLOW, + CaptureNotice.PROFILE_UPDATE_FAILED, + CaptureNotice.PENDING_QUEUE_FULL, + CaptureNotice.PENDING_OVERFLOW, + CaptureNotice.ADMISSION_IN_PROGRESS, + // Platoon data of an identified flow could not be saved. + CaptureNotice.ROSTER_SAVE_FAILED, + CaptureNotice.ACTIVITY_SAVE_FAILED, + CaptureNotice.UPDATES_SAVE_FAILED, + ) + + fun stopsTaps(notice: CaptureNotice): Boolean = notice in LOGGED_IN_NOTICES + + /** + * How a run ends at once after [notice], or null when it goes on. A client that cannot be + * verified keeps every flow of this login quarantined, so waiting for a roster is pointless. + */ + fun endsRun(notice: CaptureNotice): StandbyOutcome? = + if (notice == CaptureNotice.CLIENT_UNVERIFIED) StandbyOutcome.CLIENT_UNVERIFIED else null + + /** A tap is due at [now] only inside the tap window and before any sign of login. */ + fun tapDue(now: Long, nextTapAt: Long, tapsUntil: Long, loggedIn: Boolean): Boolean = + !loggedIn && now >= nextTapAt && now < tapsUntil +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 4b709ea..2e37885 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -630,6 +630,7 @@ 멤버 기록 완료 다른 서클로 접속됨 제한 시간 안에 멤버 정보가 들어오지 않음 + 게임에 로그인했지만 클라이언트를 확인하지 못함 시간 안에 시작하지 못함 접근성 서비스가 꺼져 있음 VPN 동의가 없음 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5115187..085d133 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -638,6 +638,7 @@ Members recorded Logged in to a different Platoon No members data before the time limit + The game logged in, but its client could not be verified Not started in time Accessibility service was off VPN consent was missing diff --git a/app/src/test/java/dev/gf2log/app/standby/StandbyTapPolicyTest.kt b/app/src/test/java/dev/gf2log/app/standby/StandbyTapPolicyTest.kt new file mode 100644 index 0000000..dab8d1c --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/standby/StandbyTapPolicyTest.kt @@ -0,0 +1,81 @@ +package dev.gf2log.app.standby + +import dev.gf2log.app.capture.CaptureNotice +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class StandbyTapPolicyTest { + + @Test + fun `a rejected platoon profile stops the taps like an accepted one`() { + listOf( + CaptureNotice.CLIENT_UNVERIFIED, + CaptureNotice.PROFILE_UPDATE_FAILED, + CaptureNotice.IDENTIFIED_FLOW_BUFFER_OVERFLOW, + CaptureNotice.PLATOON_DETECTED, + CaptureNotice.ROSTER_SAVED, + ).forEach { notice -> assertTrue("$notice", StandbyTapPolicy.stopsTaps(notice)) } + } + + @Test + fun `notices a title screen can cause do not stop the taps`() { + listOf( + CaptureNotice.PREPARING, + CaptureNotice.CAPTURING, + CaptureNotice.TRAFFIC, + CaptureNotice.FLOW_BUFFER_OVERFLOW, + ).forEach { notice -> assertFalse("$notice", StandbyTapPolicy.stopsTaps(notice)) } + } + + @Test + fun `no tap is sent after a post-login notice`() { + listOf( + CaptureNotice.CLIENT_UNVERIFIED, + CaptureNotice.PROFILE_UPDATE_FAILED, + CaptureNotice.IDENTIFIED_FLOW_BUFFER_OVERFLOW, + ).forEach { notice -> + val taps = simulateTaps(noticeAt = 20_000L, notice = notice) + assertTrue("$notice: taps after it at ${taps.filter { it > 20_000L }}", taps.none { it > 20_000L }) + assertEquals("$notice: only the 15 s and 19 s taps", listOf(15_000L, 19_000L), taps) + } + } + + @Test + fun `without any notice the taps stop at the end of the window`() { + val taps = simulateTaps(noticeAt = null, notice = null) + + assertEquals(15_000L, taps.first()) + assertTrue(taps.last() < TAP_WINDOW_MS) + } + + @Test + fun `an unverifiable client ends the run at once with its own outcome`() { + assertEquals(StandbyOutcome.CLIENT_UNVERIFIED, StandbyTapPolicy.endsRun(CaptureNotice.CLIENT_UNVERIFIED)) + assertNull(StandbyTapPolicy.endsRun(CaptureNotice.PROFILE_UPDATE_FAILED)) + assertNull(StandbyTapPolicy.endsRun(CaptureNotice.PLATOON_DETECTED)) + } + + /** Ticks once a second through a run, as StandbyAutomation does, and returns when taps went out. */ + private fun simulateTaps(noticeAt: Long?, notice: CaptureNotice?): List { + var nextTapAt = FIRST_TAP_DELAY_MS + var loggedIn = false + val taps = mutableListOf() + for (now in 0L..TAP_WINDOW_MS + 10_000L step 1_000L) { + if (noticeAt != null && notice != null && now >= noticeAt && StandbyTapPolicy.stopsTaps(notice)) loggedIn = true + if (StandbyTapPolicy.tapDue(now, nextTapAt, TAP_WINDOW_MS, loggedIn)) { + nextTapAt = now + TAP_INTERVAL_MS + taps += now + } + } + return taps + } + + private companion object { + const val FIRST_TAP_DELAY_MS = 15_000L + const val TAP_INTERVAL_MS = 4_000L + const val TAP_WINDOW_MS = 120_000L + } +} From 64a0a29337aca9f3c11c0372ec58bc7fc8fe6f35 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:15:36 +0900 Subject: [PATCH 41/98] Recheck that capture is stopped where Platoon maintenance runs The capture guard asked only before the follow-up dialogs and file picker, so a capture that standby or an alarm started meanwhile could race a Platoon deletion, a server change, or a restore. These now run only while capture is stopped at that moment, and a capture start is refused until they finish. A deleted or moved Platoon also replans the next scheduled recording at once, from any screen. Findings: data-lifecycle-races-m3, data-deletion-remnants-2 --- .../main/java/dev/gf2log/app/BackupFlow.kt | 19 ++++- .../main/java/dev/gf2log/app/MainActivity.kt | 8 +- .../dev/gf2log/app/PlatoonProfileSelector.kt | 16 +++- .../gf2log/app/standby/StandbyAutomation.kt | 8 ++ .../java/dev/gf2log/app/ui/CaptureGuard.kt | 45 ++++++++++++ app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../dev/gf2log/app/ui/CaptureGuardTest.kt | 73 +++++++++++++++++++ 8 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/ui/CaptureGuardTest.kt diff --git a/app/src/main/java/dev/gf2log/app/BackupFlow.kt b/app/src/main/java/dev/gf2log/app/BackupFlow.kt index 7744cf4..eff7e53 100644 --- a/app/src/main/java/dev/gf2log/app/BackupFlow.kt +++ b/app/src/main/java/dev/gf2log/app/BackupFlow.kt @@ -82,9 +82,13 @@ internal class BackupFlow( work.run( key = maintenanceKey, task = { - DocumentAccess.openInput(activity.contentResolver, uri).use { input -> - PlatoonBackupManager.restoreSelected(activity, input) - } + // Checked again where it runs: standby or an alarm may have started capture while + // the file was being chosen. + CaptureGuard.whileStopped { + DocumentAccess.openInput(activity.contentResolver, uri).use { input -> + PlatoonBackupManager.restoreSelected(activity, input) + } + } ?: throw CaptureGuard.CaptureActiveException() }, ) { result -> result.fold( @@ -96,7 +100,14 @@ internal class BackupFlow( ) activity.recreate() }, - onFailure = { Feedback.show(activity, fullBackupRestoreFailureMessage(it), long = true) }, + onFailure = { + val message = if (it is CaptureGuard.CaptureActiveException) { + R.string.stop_capture_before_backup + } else { + fullBackupRestoreFailureMessage(it) + } + Feedback.show(activity, message, long = true) + }, ) } } diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 64e4a72..9c9d5bd 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -224,7 +224,13 @@ class MainActivity : BaseActivity() { } private fun startCaptureService() { - CaptureStatus.markStarting() + if (!CaptureGuard.beginCaptureStart()) { + // A Platoon delete, server change, or restore is still running on its scope. + Feedback.show(this, R.string.capture_start_after_maintenance, long = true) + captureOnceRequested = false + startPending = false + return + } startForegroundService( Intent(this, CaptureVpnService::class.java) .setAction(CaptureVpnService.ACTION_START) diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index 5c6e2c2..b5aa58c 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -13,6 +13,7 @@ import dev.gf2log.app.management.PlatoonProfile import dev.gf2log.app.management.PlatoonProfileAdministration import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.standby.ScheduledRecording import dev.gf2log.app.ui.onTextChanged import dev.gf2log.app.ui.AccessibleRow import dev.gf2log.app.ui.Dialogs @@ -277,17 +278,26 @@ internal object PlatoonProfileSelector { val screen = WeakReference(activity) val appContext = activity.applicationContext maintenanceExecutor.execute { - val result = runCatching(operation) + // Checked again where it runs: standby or an alarm may have started capture meanwhile. + val result = CaptureGuard.whileStopped { runCatching(operation) } + ?: Result.failure(CaptureGuard.CaptureActiveException()) + // The next scheduled wake-up must not be for a Platoon that is gone or has moved. + if (result.isSuccess && ScheduledRecording.isEnabled(appContext)) ScheduledRecording.reschedule(appContext) + val message = if (result.exceptionOrNull() is CaptureGuard.CaptureActiveException) { + R.string.stop_capture_before_profile_change + } else { + failureMessage + } // A rotation may have replaced the caller; refresh whichever screen is showing now. val current = screen.get()?.takeUnless { it.isFinishing || it.isDestroyed } ?: BaseActivity.foregroundScreen() if (current == null) { - if (result.isFailure) Feedback.show(appContext, failureMessage, long = true) + if (result.isFailure) Feedback.show(appContext, message, long = true) return@execute } current.runOnUiThread { if (current.isFinishing || current.isDestroyed) return@runOnUiThread - if (result.isSuccess) current.recreate() else Feedback.show(current, failureMessage, long = true) + if (result.isSuccess) current.recreate() else Feedback.show(current, message, long = true) } } } 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 18dd931..b9d0e61 100644 --- a/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt +++ b/app/src/main/java/dev/gf2log/app/standby/StandbyAutomation.kt @@ -17,6 +17,7 @@ import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.capture.CaptureVpnService import dev.gf2log.app.capture.RosterEvents import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.ui.CaptureGuard import java.lang.ref.WeakReference import java.time.Instant import java.util.concurrent.Executors @@ -235,6 +236,12 @@ internal object StandbyAutomation { // 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 + // A Platoon is being deleted, moved to another server, or restored: its storage must not + // be written meanwhile. + if (!CaptureGuard.beginCaptureStart()) { + Log.w(TAG, "Capture not started: Platoon maintenance is running") + return endRun(StandbyOutcome.CAPTURE_FAILED) + } val started = runCatching { appContext.startForegroundService( Intent(appContext, CaptureVpnService::class.java) @@ -246,6 +253,7 @@ internal object StandbyAutomation { startedCapture = true } else { Log.w(TAG, "Capture could not start", started.exceptionOrNull()) + if (capture.phase == CapturePhase.STOPPED) CaptureGuard.abandonCaptureStart() endRun(StandbyOutcome.CAPTURE_FAILED) } } 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 04a4664..b39312e 100644 --- a/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt +++ b/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt @@ -18,6 +18,10 @@ internal val captureActive: Boolean /** * Data operations that must not interleave with capture ask once and stop it in place, instead of * sending the user back to Home to find the Stop button. + * + * Asking happens before any follow-up dialog or file picker, so a capture that standby or an alarm + * starts meanwhile is caught again where the operation runs: [whileStopped] runs it only while + * capture is stopped, and [beginCaptureStart] refuses a start until it has finished. */ internal object CaptureGuard { /** Teardown joins the native loop and drains queued chunks; anything longer means it is stuck. */ @@ -25,6 +29,47 @@ internal object CaptureGuard { private val handler by lazy { Handler(Looper.getMainLooper()) } + /** Orders a maintenance check against a capture start, so one of them always sees the other. */ + private val lock = Any() + + /** Operations inside [whileStopped] right now; guarded by [lock]. */ + private var maintenance = 0 + + /** Thrown by callers whose operation [whileStopped] refused because capture was active. */ + class CaptureActiveException : IllegalStateException("Capture became active before the operation ran") + + /** + * Runs [operation] on the calling thread if capture is STOPPED at this moment, and keeps every + * capture start out until it returns. Null, without running it, when capture is active. + */ + fun whileStopped(operation: () -> T): T? { + synchronized(lock) { + if (CaptureStatus.snapshot().phase != CapturePhase.STOPPED) return null + maintenance++ + } + try { + return operation() + } finally { + synchronized(lock) { maintenance-- } + } + } + + /** + * Call right before asking the capture service to start: publishes STARTING (when capture is + * stopped) so no maintenance begins after it, or returns false, and the start must not be + * sent, while one is running. + */ + fun beginCaptureStart(): Boolean = synchronized(lock) { + if (maintenance > 0) return false + if (CaptureStatus.snapshot().phase == CapturePhase.STOPPED) CaptureStatus.markStarting() + true + } + + /** A start [beginCaptureStart] allowed never reached the service: back to STOPPED. */ + fun abandonCaptureStart() = synchronized(lock) { + if (CaptureStatus.snapshot().phase == CapturePhase.STARTING) CaptureStatus.markStopped() + } + /** 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)) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 2e37885..095840f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -605,6 +605,7 @@ 캡처 중지 후 계속 캡처를 아직 중지하는 중입니다 캡처가 끝나지 않아 아무것도 바꾸지 않았습니다. 홈에 준비됨이 표시되면 다시 시도하세요. + 서클 변경이나 복원이 아직 진행 중입니다. 끝난 뒤 캡처를 다시 시작하세요. 출석이 아니요이면 과업을 예로 둘 수 없습니다. 대기 모드 켜기 대기 모드 실행 중 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 085d133..2cbed9f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -613,6 +613,7 @@ Stop capture Capture is still stopping Capture didn\'t finish stopping, so nothing was changed. Wait until Home shows Ready, then try again. + A Platoon change or restore is still running. Start capture again once it finishes. Patrol can\'t be yes while login is no. Turn on standby mode Standby mode is on diff --git a/app/src/test/java/dev/gf2log/app/ui/CaptureGuardTest.kt b/app/src/test/java/dev/gf2log/app/ui/CaptureGuardTest.kt new file mode 100644 index 0000000..1d0a3c6 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/ui/CaptureGuardTest.kt @@ -0,0 +1,73 @@ +package dev.gf2log.app.ui + +import dev.gf2log.app.capture.CapturePhase +import dev.gf2log.app.capture.CaptureStatus +import dev.gf2log.app.capture.MainThread +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CaptureGuardTest { + @Before + fun setUp() { + MainThread.testExecutor = { it() } + CaptureStatus.markStopped() + } + + @After + fun tearDown() { + CaptureStatus.markStopped() + MainThread.testExecutor = null + } + + @Test + fun `maintenance runs while capture is stopped`() { + assertEquals("deleted", CaptureGuard.whileStopped { "deleted" }) + } + + @Test + fun `a capture started after the user was asked keeps the deletion from running`() { + // The guard passed while STOPPED; standby or an alarm then started capture before the + // user finished confirming. + assertEquals(CapturePhase.STOPPED, CaptureStatus.snapshot().phase) + assertTrue(CaptureGuard.beginCaptureStart()) + var deleted = false + + val result = CaptureGuard.whileStopped { deleted = true } + + assertNull(result) + assertFalse(deleted) + } + + @Test + fun `no capture start is allowed while maintenance runs`() { + CaptureGuard.whileStopped { + assertFalse(CaptureGuard.beginCaptureStart()) + assertEquals(CapturePhase.STOPPED, CaptureStatus.snapshot().phase) + } + + assertTrue(CaptureGuard.beginCaptureStart()) + assertEquals(CapturePhase.STARTING, CaptureStatus.snapshot().phase) + } + + @Test + fun `a failing maintenance still lets capture start afterwards`() { + runCatching { CaptureGuard.whileStopped { error("disk full") } } + + assertTrue(CaptureGuard.beginCaptureStart()) + } + + @Test + fun `a start that never reached the service is taken back`() { + assertTrue(CaptureGuard.beginCaptureStart()) + + CaptureGuard.abandonCaptureStart() + + assertEquals(CapturePhase.STOPPED, CaptureStatus.snapshot().phase) + assertEquals(Unit, CaptureGuard.whileStopped { }) + } +} From d5ebd737e75b28c99164f9aafe4446775c674c30 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:15:58 +0900 Subject: [PATCH 42/98] Warn that standby keeps a phone with a screen lock unlocked The all-black, minimum-brightness screen looks like a phone that is off, but standby keeps it unlocked until it ends. On a phone with a PIN, pattern, or password the user now agrees to that once before standby first opens, and the hint shown on entry says the phone stays unlocked. Finding: sec-components-3 --- .../java/dev/gf2log/app/StandbyActivity.kt | 13 ++++++-- .../main/java/dev/gf2log/app/StandbyFlow.kt | 19 +++++++++++ .../app/standby/StandbyUnlockedWarning.kt | 32 +++++++++++++++++++ app/src/main/res/values-ko/strings.xml | 3 ++ app/src/main/res/values/strings.xml | 3 ++ .../app/standby/StandbyUnlockedWarningTest.kt | 20 ++++++++++++ 6 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/standby/StandbyUnlockedWarning.kt create mode 100644 app/src/test/java/dev/gf2log/app/standby/StandbyUnlockedWarningTest.kt diff --git a/app/src/main/java/dev/gf2log/app/StandbyActivity.kt b/app/src/main/java/dev/gf2log/app/StandbyActivity.kt index 19fe8c2..85c5495 100644 --- a/app/src/main/java/dev/gf2log/app/StandbyActivity.kt +++ b/app/src/main/java/dev/gf2log/app/StandbyActivity.kt @@ -18,6 +18,7 @@ import dev.gf2log.app.standby.ScheduledRecording import dev.gf2log.app.standby.StandbyAutomation import dev.gf2log.app.standby.StandbyAutomationService import dev.gf2log.app.standby.StandbyPlanner +import dev.gf2log.app.standby.StandbyUnlockedWarning import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.TextRole import dev.gf2log.app.ui.dp @@ -71,14 +72,20 @@ class StandbyActivity : BaseActivity() { } // Shown on every entry, so someone who skipped the first-time explanation still learns - // what this black screen is and how to leave it. + // what this black screen is and how to leave it, and on a phone with a screen lock that it + // is not locked meanwhile (a scheduled recording never runs on one). + val leaveHint = when { + scheduled -> R.string.scheduled_touch_to_cancel + StandbyUnlockedWarning.isDeviceSecure(this) -> R.string.standby_touch_to_leave_unlocked + else -> R.string.standby_touch_to_leave + } val hint = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL addView(text(getString(if (scheduled) R.string.scheduled_running else R.string.standby_running), TextRole.TITLE) { gravity = Gravity.CENTER setTextColor(getColor(R.color.standby_hint)) }) - addView(text(getString(if (scheduled) R.string.scheduled_touch_to_cancel else R.string.standby_touch_to_leave), TextRole.SECONDARY) { + addView(text(getString(leaveHint), TextRole.SECONDARY) { gravity = Gravity.CENTER setTextColor(getColor(R.color.standby_hint)) setPadding(0, dp(6), 0, 0) @@ -93,7 +100,7 @@ class StandbyActivity : BaseActivity() { } root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) - contentDescription = getString(if (scheduled) R.string.scheduled_touch_to_cancel else R.string.standby_touch_to_leave) + contentDescription = getString(leaveHint) setOnClickListener { leave() } addView(hint, FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, diff --git a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt index dca6ab8..17c29a8 100644 --- a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt +++ b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt @@ -14,6 +14,7 @@ import dev.gf2log.app.standby.StandbyPlanner import dev.gf2log.app.standby.StandbyProfiles import dev.gf2log.app.standby.StandbyResults import dev.gf2log.app.standby.StandbyServer +import dev.gf2log.app.standby.StandbyUnlockedWarning import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Feedback @@ -49,6 +50,7 @@ internal class StandbyFlow( } fun start() { + if (StandbyUnlockedWarning.shouldWarn(activity)) return warnUnlocked() if (!StandbyBatteryPrompt.shouldAsk(activity)) return prepareRecording() Dialogs.show( Dialogs.builder(activity) @@ -67,6 +69,23 @@ internal class StandbyFlow( ) } + /** + * The black screen looks like a phone that is off, so on a phone with a screen lock the user + * agrees once, before standby first opens, that it stays unlocked until standby ends. + */ + private fun warnUnlocked() { + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.standby_unlocked_title) + .setMessage(R.string.standby_unlocked_message) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.continue_action) { _, _ -> + StandbyUnlockedWarning.acknowledge(activity) + start() + }, + ) + } + /** * Shows the last standby session's results when Home opens; they count as seen only once the * user closes them, so a Home briefly resumed during automation cannot use them up. diff --git a/app/src/main/java/dev/gf2log/app/standby/StandbyUnlockedWarning.kt b/app/src/main/java/dev/gf2log/app/standby/StandbyUnlockedWarning.kt new file mode 100644 index 0000000..1f0c182 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/standby/StandbyUnlockedWarning.kt @@ -0,0 +1,32 @@ +package dev.gf2log.app.standby + +import android.app.KeyguardManager +import android.content.Context +import androidx.core.content.edit + +/** + * Standby keeps the phone unlocked behind a black screen that looks switched off. On a phone with + * a PIN, pattern, or password, the user is told so, and agrees, once before standby first opens. + */ +internal object StandbyUnlockedWarning { + + fun shouldWarn(context: Context): Boolean = + isRequired(secure = isDeviceSecure(context), acknowledged = prefs(context).getBoolean(KEY_ACKNOWLEDGED, false)) + + /** Only a screen lock that standby leaves open needs the warning, and only until it is agreed to. */ + fun isRequired(secure: Boolean, acknowledged: Boolean): Boolean = secure && !acknowledged + + fun acknowledge(context: Context) { + prefs(context).edit { putBoolean(KEY_ACKNOWLEDGED, true) } + } + + /** Unknown counts as secure, so the warning is never skipped by a missing service. */ + fun isDeviceSecure(context: Context): Boolean = + context.getSystemService(KeyguardManager::class.java)?.isDeviceSecure != false + + private fun prefs(context: Context) = + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private const val PREFS = "standby_mode" + private const val KEY_ACKNOWLEDGED = "unlocked_warning_acknowledged" +} diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 095840f..31eb5e7 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -610,6 +610,9 @@ 대기 모드 켜기 대기 모드 실행 중 화면을 누르면 대기 모드가 해제됩니다. + 화면을 누르면 대기 모드가 해제됩니다. 그때까지 휴대전화는 잠기지 않습니다. + 휴대전화가 잠기지 않은 채로 유지됩니다 + 대기 모드는 끝날 때까지 휴대전화를 잠그지 않습니다. 검은 화면이 꺼진 것처럼 보여도, 그동안 휴대전화를 집은 사람은 PIN·패턴·비밀번호 없이 사용할 수 있습니다. 밤새 대기 모드 유지 허용 Android는 배터리를 아끼기 위해 백그라운드의 GF2logger를 중지할 수 있습니다. 배터리 최적화 예외로 설정하면 대기 모드가 밤새 유지됩니다. 이 설정은 나중에 앱의 배터리 설정에서 바꿀 수 있습니다. 허용 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cbed9f..45d124b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -618,6 +618,9 @@ Turn on standby mode Standby mode is on Tap the screen to leave standby mode. + Tap the screen to leave standby mode. Until then the phone stays unlocked. + Your phone stays unlocked + Standby mode keeps your phone unlocked until it ends, even though the black screen looks switched off. Anyone who picks the phone up meanwhile can use it without your PIN, pattern, or password. Allow standby mode to run overnight Android may stop GF2logger in the background to save battery. Exclude it from battery optimization to keep standby mode running overnight. You can change this later in the app\'s battery settings. Allow diff --git a/app/src/test/java/dev/gf2log/app/standby/StandbyUnlockedWarningTest.kt b/app/src/test/java/dev/gf2log/app/standby/StandbyUnlockedWarningTest.kt new file mode 100644 index 0000000..6f9e57c --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/standby/StandbyUnlockedWarningTest.kt @@ -0,0 +1,20 @@ +package dev.gf2log.app.standby + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StandbyUnlockedWarningTest { + + @Test + fun `a phone with a screen lock is warned until the user agrees`() { + assertTrue(StandbyUnlockedWarning.isRequired(secure = true, acknowledged = false)) + assertFalse(StandbyUnlockedWarning.isRequired(secure = true, acknowledged = true)) + } + + @Test + fun `a phone without a screen lock has nothing to be warned about`() { + assertFalse(StandbyUnlockedWarning.isRequired(secure = false, acknowledged = false)) + assertFalse(StandbyUnlockedWarning.isRequired(secure = false, acknowledged = true)) + } +} From 6ba6a4196d5de3ddd0f82887e751bfea7d8433ef Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:15:58 +0900 Subject: [PATCH 43/98] Mark standby results seen only when the user closes them Home's recreation dismissed the open results dialog, and its dismiss listener marked the results seen, so a theme, font-scale, or language change used them up unread. Only OK, Back, or a touch outside now does. Finding: q-uiux-6 --- app/src/main/java/dev/gf2log/app/StandbyFlow.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt index 17c29a8..f225755 100644 --- a/app/src/main/java/dev/gf2log/app/StandbyFlow.kt +++ b/app/src/main/java/dev/gf2log/app/StandbyFlow.kt @@ -88,7 +88,8 @@ internal class StandbyFlow( /** * Shows the last standby session's results when Home opens; they count as seen only once the - * user closes them, so a Home briefly resumed during automation cannot use them up. + * user closes them (OK, Back, or a touch outside), so neither a Home briefly resumed during + * automation nor one closed or recreated with the dialog open can use them up. */ fun showUnseenResults() { if (StandbyAutomation.isArmed || resultsDialog?.isShowing == true) return @@ -103,11 +104,15 @@ internal class StandbyFlow( Labels.format(Labels.DATE_TIME, Instant.ofEpochMilli(result.atMillis), zone), ) } - resultsDialog = Dialogs.message(activity, activity.getString(R.string.standby_results_title), lines).apply { - setOnDismissListener { - StandbyResults.markSeen(activity, results) - resultsDialog = null - } + resultsDialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.standby_results_title) + .setMessage(lines) + .setOnCancelListener { StandbyResults.markSeen(activity, results) } + .setPositiveButton(android.R.string.ok) { _, _ -> StandbyResults.markSeen(activity, results) }, + ).apply { + // Also dismissed, without the user, when Home is destroyed; that must not mark them seen. + setOnDismissListener { resultsDialog = null } } } From cbc30e1f619b1938095346cd708db8a01916d637 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:16:05 +0900 Subject: [PATCH 44/98] Describe exactly when standby taps and locks the phone The texts said the phone locks after the last server and that taps hit only the title screen. Android 8 has no lock action, so there the phone is left to its screen timeout; tapping the standby screen or switching apps leaves standby without locking; and taps repeat at one point until the game reports a login, for two minutes at most. Findings: sec-doc-claims-2, sec-doc-claims-5 --- README.md | 19 ++++++++++++------- README_KR.md | 2 +- app/src/main/res/values-ko/strings.xml | 6 +++--- app/src/main/res/values/strings.xml | 6 +++--- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7da7c9d..5f0c822 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,18 @@ never stored. - Provides profile management for correcting server metadata without moving data and for deleting one isolated Platoon behind two confirmations and an exact-name check. - Guides first-time users through Main, Settings, Platoon management, weekly controls, and parsed-packet pages, with a persistent English/Korean selector and Skip action; the guide can be replayed from Settings. - Offers an overnight standby mode from Home's moon button: an all-black, - minimum-brightness screen that keeps the phone awake. With the optional - GF2logger accessibility service turned on, standby records members by itself - five minutes before each chosen server's daily reset: it starts capture, opens - the game, taps Start on the title screen, waits for the roster, closes the game - from Recents, and locks the phone after the last server. The service only taps - while the game is in front and reads nothing on screen except the foreground - app and the app names on Recents cards. + minimum-brightness screen that keeps the phone awake and unlocked until + standby ends; on a phone with a PIN, pattern, or password it says so and asks + once before the first use. With the optional GF2logger accessibility service + turned on, standby records members by itself five minutes before each chosen + server's daily reset: it starts capture, opens the game, taps where Start sits + on the title screen (every few seconds, until the game reports a login, and + for two minutes at most), waits for the roster, closes the game from Recents, + and locks the phone after the last server. Android 8 offers no lock action, so + there the phone is left to its own screen timeout. Tapping the standby screen + or switching to another app leaves standby without locking the phone. The + service only taps while the game is in front and reads nothing on screen + except the foreground app and the app names on Recents cards. - On a phone without a PIN, pattern, or password, can instead record every day on a schedule from Settings: an alarm wakes the phone before each chosen server's reset, records members the same way, and turns the screen off again. diff --git a/README_KR.md b/README_KR.md index 4d3891a..a4acc1f 100644 --- a/README_KR.md +++ b/README_KR.md @@ -33,7 +33,7 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android - 새 서클은 확인된 클라이언트에 맞는 서버를 사용자가 선택할 때까지 패킷을 제한된 메모리에만 보관하며, 강제 종료 또는 프로세스 종료 시 확인되지 않은 데이터를 버립니다. - 서버 정보는 데이터를 이동하지 않고 수정할 수 있으며, 서클 삭제는 두 번의 확인과 정확한 서클 이름 입력을 거쳐 해당 격리 데이터만 제거합니다. - 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다. 안내는 설정에서 다시 볼 수 있습니다. -- 홈의 달 버튼으로 밤샘 대기 모드를 켤 수 있습니다. 화면을 완전히 검게, 밝기를 최소로 두고 휴대전화를 깨어 있게 유지합니다. 선택 사항인 GF2logger 접근성 서비스를 켜면, 선택한 서버마다 일일 초기화 5분 전에 캡처를 시작하고 게임을 열어 타이틀 화면의 시작을 누른 뒤 멤버 명단을 기다렸다가, 최근 앱 화면에서 게임을 닫고 마지막 서버가 끝나면 휴대전화를 잠급니다. 이 서비스는 게임이 앞에 있을 때만 누르며, 앞에 있는 앱과 최근 앱 카드의 앱 이름 외에는 화면 내용을 읽지 않습니다. +- 홈의 달 버튼으로 밤샘 대기 모드를 켤 수 있습니다. 화면을 완전히 검게, 밝기를 최소로 두고 대기 모드가 끝날 때까지 휴대전화를 잠그지 않은 채 깨어 있게 유지합니다. PIN·패턴·비밀번호가 있는 휴대전화에서는 처음 사용할 때 이 점을 알리고 한 번 확인받습니다. 선택 사항인 GF2logger 접근성 서비스를 켜면, 선택한 서버마다 일일 초기화 5분 전에 캡처를 시작하고 게임을 열어 타이틀 화면의 시작 위치를 누른 뒤(게임이 로그인을 알릴 때까지 몇 초마다, 최대 2분) 멤버 명단을 기다렸다가, 최근 앱 화면에서 게임을 닫고 마지막 서버가 끝나면 휴대전화를 잠급니다. Android 8에는 잠금 동작이 없어 화면 자동 꺼짐에 맡깁니다. 대기 화면을 누르거나 다른 앱으로 전환하면 휴대전화를 잠그지 않은 채 대기 모드가 끝납니다. 이 서비스는 게임이 앞에 있을 때만 누르며, 앞에 있는 앱과 최근 앱 카드의 앱 이름 외에는 화면 내용을 읽지 않습니다. - PIN·패턴·비밀번호가 없는 휴대전화에서는 설정의 예약 자동 기록으로 매일 기록할 수도 있습니다. 선택한 서버마다 초기화 전에 알람이 휴대전화를 깨워 같은 방식으로 멤버를 기록하고 화면을 다시 끕니다. - 화면을 가장자리까지 그리며, 회전·테마·언어 변경이나 프로세스 재생성 후에도 작성 중인 내용과 선택, 대기 중인 내보내기를 유지합니다. - 영어와 한국어, 시스템/라이트/다크 테마를 지원합니다. 알려진 Darkwinter/HaoPlay 6개 서버 지역의 초기화 시각을 기기 시간대로 환산하며, 감지된 서클을 선택하면 해당 프로필에 저장된 지역을 자동으로 따릅니다. diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 31eb5e7..6276366 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -617,15 +617,15 @@ Android는 배터리를 아끼기 위해 백그라운드의 GF2logger를 중지할 수 있습니다. 배터리 최적화 예외로 설정하면 대기 모드가 밤새 유지됩니다. 이 설정은 나중에 앱의 배터리 설정에서 바꿀 수 있습니다. 허용 나중에 - 대기 모드의 자동 기록에만 사용됩니다. 게임 시작 화면을 누르고, 최근 앱 화면에서 게임을 닫고, 홈으로 나간 뒤 휴대전화를 잠급니다. 어떤 앱이 앞에 있는지와 최근 앱 카드의 앱 이름만 확인하며, 그 밖의 화면 내용은 읽지 않습니다. + 대기 모드의 자동 기록에만 사용됩니다. 게임이 로그인할 때까지(최대 2분) 타이틀 화면의 시작 위치를 누르고, 최근 앱 화면에서 게임을 닫고, 홈으로 나간 뒤 Android 9 이상에서는 휴대전화를 잠급니다. 어떤 앱이 앞에 있는지와 최근 앱 카드의 앱 이름만 확인하며, 그 밖의 화면 내용은 읽지 않습니다. 자동 기록 켜기 - 자는 동안 서클 멤버를 기록하려면 GF2logger의 접근성 서비스가 게임 시작 화면을 누르고, 게임을 닫은 뒤 휴대전화를 잠가야 합니다. 접근성 설정을 열어 GF2logger를 선택하고 켜세요. Android가 허용하지 않으면 먼저 GF2logger 앱 정보의 ⋮ 메뉴에서 제한된 설정을 허용하세요. + 자는 동안 서클 멤버를 기록하려면 GF2logger의 접근성 서비스가 게임 타이틀 화면을 누르고, 게임을 닫은 뒤 휴대전화를 잠가야 합니다(Android 8에서는 이렇게 잠글 수 없어 화면 자동 꺼짐에 맡깁니다). 접근성 설정을 열어 GF2logger를 선택하고 켜세요. Android가 허용하지 않으면 먼저 GF2logger 앱 정보의 ⋮ 메뉴에서 제한된 설정을 허용하세요. 설정 열기 대기 모드만 켜기 저장된 서클이 없어 대기 모드가 기록하지 않습니다. 먼저 서클을 한 번 캡처하세요. 자동 기록에는 VPN 동의가 필요합니다. 이번 대기 모드에서는 기록하지 않습니다. 기록할 서버 선택 - 선택한 서버마다 게임 하루가 끝나기 5분 전에 기록한 뒤 휴대전화를 잠급니다. 클라이언트는 한 번에 한 서버에만 접속하므로 클라이언트마다 서버를 하나씩 선택하세요. + 선택한 서버마다 게임 하루가 끝나기 5분 전에 기록한 뒤 휴대전화를 잠급니다(Android 8에서는 화면 자동 꺼짐에 맡깁니다). 그때까지 휴대전화는 잠기지 않으며, 화면을 누르거나 다른 앱으로 전환하면 잠그지 않은 채 대기 모드가 끝납니다. 클라이언트는 한 번에 한 서버에만 접속하므로 클라이언트마다 서버를 하나씩 선택하세요. %1$s · %2$s · %3$s 시작 대기 모드 시작 다음 자동 기록: %1$s · %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 45d124b..a12b151 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -625,15 +625,15 @@ Android may stop GF2logger in the background to save battery. Exclude it from battery optimization to keep standby mode running overnight. You can change this later in the app\'s battery settings. Allow Not now - Used only by standby mode\'s automatic recording: taps the game\'s start screen, closes the game from Recents, returns Home, and locks the phone afterwards. It reads only which app is in front and the app names on Recents cards, nothing else on screen. + Used only by standby mode\'s automatic recording: taps where Start sits on the game\'s title screen until the game logs in (for two minutes at most), closes the game from Recents, returns Home, and locks the phone afterwards on Android 9 and later. It reads only which app is in front and the app names on Recents cards, nothing else on screen. Turn on automatic recording - To record your Platoon members while you sleep, GF2logger needs its accessibility service to tap the game\'s start screen, close the game, and lock the phone afterwards. Open Accessibility settings, choose GF2logger, and turn it on. If Android does not allow it, first allow restricted settings from the ⋮ menu in GF2logger\'s App info. + To record your Platoon members while you sleep, GF2logger needs its accessibility service to tap the game\'s title screen, close the game, and lock the phone afterwards (Android 8 cannot be locked this way, so the phone is left to its screen timeout). Open Accessibility settings, choose GF2logger, and turn it on. If Android does not allow it, first allow restricted settings from the ⋮ menu in GF2logger\'s App info. Open settings Standby only No Platoon is saved yet, so standby mode will not record. Capture your Platoon once first. Automatic recording needs VPN consent. Standby mode will not record this time. Choose servers to record - Each chosen server is recorded 5 minutes before its game day ends, then the phone locks. A client logs in to one server at a time, so choose one server per client. + Each chosen server is recorded 5 minutes before its game day ends, then the phone locks (on Android 8 it is left to its screen timeout). Until then the phone stays unlocked, and tapping the screen or switching apps leaves standby without locking it. A client logs in to one server at a time, so choose one server per client. %1$s · %2$s · starts %3$s Start standby mode Next recording: %1$s · %2$s From b8c2395858e904dcb4dc6a777e4a91606117b294 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:16:05 +0900 Subject: [PATCH 45/98] Update SECURITY.md for 2.7 and the standby surface List 2.7.x as the supported line, add standby automation and scheduled recording to the report areas, and state the boundaries of the accessibility service, the alarms and receivers, and standby's unlocked phone. Finding: sec-doc-claims-7 --- SECURITY.md | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 8616586..a4d06aa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,8 +7,10 @@ Security fixes are made on the latest published release and the current | Version | Supported | | --- | --- | -| 2.4.x | Yes | -| 2.3.x and earlier | No | +| 2.7.x | Yes | +| 2.6.x and earlier | No | + +Update this table with every minor release. ## Reporting a vulnerability @@ -28,7 +30,9 @@ Include: - the expected and observed behavior; - the impact and any user interaction required; and - whether the issue affects capture, parsing, CSV import/export, backup/restore, - Discord sharing, local persistence, or release signing. + Discord sharing, local persistence, standby automation (the accessibility + service), scheduled recording (exact alarms and the boot receiver), or + release signing. Do not test against another person's device, game account, Discord webhook, or network traffic. Remove UIDs, names, notes, webhook secrets, signing material, @@ -104,6 +108,31 @@ deduplicated, count-bounded activity and update observations accepted for persistence. A separate 32-period cap bounds report reconstruction and SQLite history work even if future callers provide a broader accepted set. +Standby mode's optional accessibility service is the most privileged component. +Its platform grant allows gestures and window-content retrieval, but it reads +only the foreground package and the app-name labels of Recents cards, observes +only window-state changes, and taps at one fixed point of the title screen +only while a supported game package is in front. Taps stop at the first capture notice that +only a logged-in game produces, and in any case after two minutes per run. Its +other actions are the global Home, Recents, and (Android 9 and newer) lock +actions, plus a 1-pixel, untouchable screen-on overlay whose hold expires at the +standby deadline by itself. Standby needs the phone unlocked with the screen on +for the whole night; tapping the standby screen or switching apps leaves it +without locking, and on Android 8 the phone is left to its own screen timeout. +On a phone with a PIN, pattern, or password the user is told before the first +use that standby keeps the phone unlocked. Scheduled recording is available +only on phones without a PIN, pattern, or password, and stops itself once one +is set. Its exact alarm reaches a non-exported receiver through an immutable +`PendingIntent`, and a reschedule receiver re-plans it after boot, an app update, or a clock, +time-zone, or exact-alarm permission change. The alarm clock's show intent, +which Android hands to any app that queries the next alarm, opens only the +exported launcher screen with no extras; the creator package it reveals is +inherent to any alarm clock. Every wake-up is re-planned from the saved +Platoons, and a server without a saved Platoon is neither woken for nor +reported. Profile deletion, server-region changes, and backup restores run only +while capture is stopped, checked where they run, and capture starts wait for +them to finish. + ## Response expectations The maintainer will acknowledge a reproducible report, assess severity, and From 9d4ae05dff7c9db73c35aac2047aec1b9a5ce2c4 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:16:39 +0900 Subject: [PATCH 46/98] Keep custom_name NULL when it equals the game name A note-only save no longer pins the current in-game name as a nickname: a blank nickname, or one equal to current_name, stores NULL so later in-game renames keep showing. Member details pre-fill only a real override with the in-game name as the hint, compare the inputs in their stored (trimmed) form, and prompt on Back whenever those differ, also while a save is in flight. Member search matches the in-game name behind a nickname and UIDs typed as shown with a leading #. Findings: q-uiux-1, q-input-1, data-doc-contracts-2, q-uiux-4, q-input-m2, q-uiux-5, q-input-m1, q-uiux-10 --- .../dev/gf2log/app/MemberDetailActivity.kt | 35 ++++++++-- .../java/dev/gf2log/app/PlatoonActivity.kt | 3 +- .../gf2log/app/management/ManualTextPolicy.kt | 9 +++ .../app/management/MemberProfileDraft.kt | 21 ++++++ .../app/management/MembershipEditing.kt | 21 ++++-- .../gf2log/app/management/PlatoonModels.kt | 20 ++++++ .../app/management/PlatoonReadQueries.kt | 4 ++ app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../app/management/MemberProfileDraftTest.kt | 70 +++++++++++++++++++ .../gf2log/app/management/MemberSearchTest.kt | 47 +++++++++++++ 11 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/management/MemberProfileDraft.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/MemberProfileDraftTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/MemberSearchTest.kt diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index fff0e11..b56c264 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -9,6 +9,7 @@ import android.widget.Button import android.widget.EditText import android.widget.LinearLayout import dev.gf2log.app.management.EvidenceSource +import dev.gf2log.app.management.MemberProfileDraft import dev.gf2log.app.management.MemberStatus import dev.gf2log.app.management.MembershipPeriod import dev.gf2log.app.management.PlatoonRepository @@ -55,6 +56,7 @@ class MemberDetailActivity : BaseActivity() { private var uid = -1L private var model: Model? = null private var inputsInitialized = false + private var finishAfterSave = false private lateinit var content: LinearLayout private lateinit var statusPanel: LinearLayout @@ -151,8 +153,10 @@ class MemberDetailActivity : BaseActivity() { return@run } model = loaded + // A blank nickname follows the in-game name, which the hint shows. + nickname.hint = loaded.status.currentName if (!inputsInitialized) { - nickname.setText(loaded.status.name) + nickname.setText(MemberProfileDraft.nicknameInput(loaded.status)) note.setText(loaded.status.note) inputsInitialized = true } @@ -225,10 +229,15 @@ class MemberDetailActivity : BaseActivity() { /** Unsaved nickname or note edits are confirmed instead of silently discarded. */ private fun leave() { - if (!::saveButton.isInitialized || !saveButton.isEnabled) { + if (!::saveButton.isInitialized || !isDirty()) { finish() return } + if (work.isBusy(SAVE)) { + // The edits are already being saved; leave once that save lands. + finishAfterSave = true + return + } Dialogs.show( Dialogs.builder(this) .setTitle(R.string.unsaved_changes_title) @@ -239,11 +248,14 @@ class MemberDetailActivity : BaseActivity() { ) } + /** Whether the inputs differ from the stored profile, compared in their stored form. */ + private fun isDirty(): Boolean { + val loaded = model?.status ?: return false + return MemberProfileDraft.isDirty(nickname.text.toString(), note.text.toString(), loaded) + } + private fun updateSaveState() { - val loaded = model?.status - saveButton.isEnabled = loaded != null && !work.isBusy(SAVE) && - (nickname.text.toString() != loaded.name || note.text.toString() != loaded.note) && - nickname.text.isNotBlank() + saveButton.isEnabled = !work.isBusy(SAVE) && isDirty() } private fun saveProfile(thenFinish: Boolean = false) { @@ -253,7 +265,16 @@ class MemberDetailActivity : BaseActivity() { work.run(key = SAVE, task = { repository.updateMember(uid, name, noteText) }) { result -> val saved = result.getOrDefault(false) Feedback.show(this, if (saved) R.string.saved else R.string.save_failed) - if (saved && thenFinish) finish() else load() + if (saved && (thenFinish || finishAfterSave)) { + finish() + return@run + } + finishAfterSave = false + // Re-seed the fields in stored form unless the user kept typing during the save. + if (saved && nickname.text.toString() == name && note.text.toString() == noteText) { + inputsInitialized = false + } + load() } updateSaveState() } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index de5e9ec..194eba4 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -19,6 +19,7 @@ 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.matchesSearch import dev.gf2log.app.management.PlatoonMemberCsv import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonSnapshot @@ -269,7 +270,7 @@ class PlatoonActivity : BaseActivity() { Filter.ALL -> true } } - .filter { query.isEmpty() || it.name.contains(query, ignoreCase = true) || it.uid.toString().contains(query) } + .filter { it.matchesSearch(query) } .let { sorted(it, loaded) } resultCount.text = resources.getQuantityString(R.plurals.member_count, visible.size, visible.size) stateAdapter.view = when { diff --git a/app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt b/app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt index c2eed02..011eaeb 100644 --- a/app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt @@ -23,6 +23,15 @@ internal object ManualTextPolicy { } } + /** + * The stored form of a custom nickname, or null when the member follows the in-game name. + * A blank value clears the override, and a value equal to [currentName] is no override at + * all, so a later in-game rename still shows instead of being frozen by a note-only save. + */ + fun customName(value: String, currentName: String): String? = value.trim().let { trimmed -> + if (trimmed.isEmpty() || trimmed == currentName) null else memberName(trimmed) + } + /** The stored form of a private member, membership-period, or weekly note. */ fun note(value: String): String = value.trim().also { require(it.length <= MAX_NOTE_CHARS) { "A note cannot exceed $MAX_NOTE_CHARS characters" } diff --git a/app/src/main/java/dev/gf2log/app/management/MemberProfileDraft.kt b/app/src/main/java/dev/gf2log/app/management/MemberProfileDraft.kt new file mode 100644 index 0000000..a449331 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/MemberProfileDraft.kt @@ -0,0 +1,21 @@ +package dev.gf2log.app.management + +/** + * The member-details nickname and note inputs compared in their stored form. The stored values + * are trimmed and a nickname equal to the in-game name is no nickname, so whitespace or the + * in-game name typed back in never counts as an unsaved change. + */ +internal object MemberProfileDraft { + /** The nickname field's initial text: only a real override, never the in-game name. */ + fun nicknameInput(status: MemberStatus): String = + storedNickname(status.customName.orEmpty(), status.currentName).orEmpty() + + fun isDirty(nicknameInput: String, noteInput: String, status: MemberStatus): Boolean = + storedNickname(nicknameInput, status.currentName) != + storedNickname(status.customName.orEmpty(), status.currentName) || + noteInput.trim() != status.note.trim() + + /** [ManualTextPolicy.customName] without its length check, which the input field enforces. */ + private fun storedNickname(value: String, currentName: String): String? = + value.trim().takeIf { it.isNotEmpty() && it != currentName } +} 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 9b2e551..fd755c5 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -65,12 +65,25 @@ internal object MembershipEditing { } } + /** + * Stores the nickname and private note. A blank nickname, or one equal to the in-game name, + * clears the override so later in-game renames keep showing. + */ fun updateMember(db: SQLiteDatabase, uid: Long, name: String, note: String): Boolean { - val values = ContentValues().apply { - put("custom_name", ManualTextPolicy.memberName(name)) - put("note", ManualTextPolicy.note(note)) + val storedNote = ManualTextPolicy.note(note) + return db.inTransaction { + val currentName = db.rawQuery( + "SELECT current_name FROM members WHERE uid = ?", + arrayOf(uid.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + ?: return@inTransaction false + val values = ContentValues().apply { + val customName = ManualTextPolicy.customName(name, currentName) + if (customName == null) putNull("custom_name") else put("custom_name", customName) + put("note", storedNote) + } + db.update("members", values, "uid = ?", arrayOf(uid.toString())) == 1 } - return db.update("members", values, "uid = ?", arrayOf(uid.toString())) == 1 } fun deleteMember(db: SQLiteDatabase, uid: Long): Boolean { 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 6abbf4e..3c9912a 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt @@ -40,6 +40,10 @@ data class MembershipPeriod( val note: String, ) +/** + * [name] is the display name: [customName] when the user set one, otherwise [currentName], the + * latest in-game name. + */ data class MemberStatus( val uid: Long, val name: String, @@ -49,8 +53,24 @@ data class MemberStatus( val lastSeenAt: Instant, val note: String, val membershipPeriods: List, + val currentName: String = name, + val customName: String? = null, ) +/** + * Whether a member-list search [query] finds this member: the display name, the in-game name + * behind a nickname, or the UID, which may be typed as shown (`#1234`). + */ +fun MemberStatus.matchesSearch(query: String): Boolean { + val trimmed = query.trim() + if (trimmed.isEmpty()) return true + if (name.contains(trimmed, ignoreCase = true) || currentName.contains(trimmed, ignoreCase = true)) { + return true + } + val uidQuery = trimmed.removePrefix("#").trim() + return uidQuery.isNotEmpty() && uidQuery.all(Char::isDigit) && uid.toString().contains(uidQuery) +} + /** * The membership period a member list shows: the open period, or the most recently closed one * when the member has withdrawn. Unknown boundaries sort before every known one. diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt index 164327d..6781442 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt @@ -109,6 +109,8 @@ internal object PlatoonReadQueries { lastSeenAt = Instant.ofEpochMilli(cursor.getLong(5)), note = cursor.getString(6), membershipPeriods = periodsByUid[uid].orEmpty(), + currentName = cursor.getString(7), + customName = cursor.getNullableString(8), ), ) } @@ -258,6 +260,8 @@ internal object PlatoonReadQueries { "first_seen_at", "last_seen_at", "note", + "current_name", + "custom_name", ) private val EVENT_COLUMNS = arrayOf( "id", diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fb3f4ec..19c8f8d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -410,7 +410,7 @@ 멤버를 불러오는 중… %1$s: 이번 주 공적 +%2$s, 누적 공적 +%3$d, 흙먼지 점수 +%4$s 수치 변화 · %1$d - 게임 닉네임 대신 모든 화면에 표시됩니다. + 게임 닉네임 대신 모든 화면에 표시됩니다. 비워 두면 게임 닉네임을 따릅니다. %2$d명 중 %1$d번째, %3$s 멤버 순서를 초기화했습니다 이 기기에만 보관됩니다. 직접 포함하지 않으면 공유 이미지에 나타나지 않습니다. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef8188d..7e19a9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -414,7 +414,7 @@ Loading members… %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. + Shown everywhere in place of the in-game name. Leave it blank to follow the in-game name. %1$d of %2$d, %3$s Member order reset Private to this device. Excluded from shared images unless you include it. diff --git a/app/src/test/java/dev/gf2log/app/management/MemberProfileDraftTest.kt b/app/src/test/java/dev/gf2log/app/management/MemberProfileDraftTest.kt new file mode 100644 index 0000000..18c12c7 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/MemberProfileDraftTest.kt @@ -0,0 +1,70 @@ +package dev.gf2log.app.management + +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class MemberProfileDraftTest { + @Test + fun `a nickname equal to the in-game name or blank stores no override`() { + assertNull(ManualTextPolicy.customName("Alice", currentName = "Alice")) + assertNull(ManualTextPolicy.customName(" Alice \n", currentName = "Alice")) + assertNull(ManualTextPolicy.customName(" ", currentName = "Alice")) + assertEquals("Carol", ManualTextPolicy.customName(" Carol ", currentName = "Alice")) + assertThrows(IllegalArgumentException::class.java) { + ManualTextPolicy.customName("n".repeat(ManualTextPolicy.MAX_MEMBER_NAME_CHARS + 1), "Alice") + } + } + + @Test + fun `the nickname field starts with the override only`() { + assertEquals("", MemberProfileDraft.nicknameInput(status(customName = null))) + assertEquals("Boss", MemberProfileDraft.nicknameInput(status(customName = "Boss"))) + // A nickname frozen to the in-game name by an older version is no override at all. + assertEquals("", MemberProfileDraft.nicknameInput(status(customName = "Alice"))) + } + + @Test + fun `whitespace around stored values is not an unsaved change`() { + val stored = status(customName = "Boss", note = "x") + + assertFalse(MemberProfileDraft.isDirty("Boss ", "x\n", stored)) + assertTrue(MemberProfileDraft.isDirty("Boss", "y", stored)) + assertTrue(MemberProfileDraft.isDirty("Chief", "x", stored)) + } + + @Test + fun `clearing a nickname or retyping the in-game name is a change only for a nicknamed member`() { + val nicknamed = status(customName = "Boss") + val plain = status(customName = null) + + assertTrue(MemberProfileDraft.isDirty("", "", nicknamed)) + assertTrue(MemberProfileDraft.isDirty("Alice", "", nicknamed)) + assertFalse(MemberProfileDraft.isDirty("", "", plain)) + assertFalse(MemberProfileDraft.isDirty("Alice", "", plain)) + assertFalse(MemberProfileDraft.isDirty("", "", status(customName = "Alice"))) + } + + @Test + fun `a note-only edit is dirty while the nickname stays blank`() { + // A blank nickname is valid, so a note edit can always be saved or confirmed on leaving. + assertTrue(MemberProfileDraft.isDirty("", "late on Sunday", status(customName = null))) + } + + private fun status(customName: String?, note: String = "") = MemberStatus( + uid = 7, + name = customName ?: "Alice", + level = 60, + isActive = true, + firstSeenAt = Instant.EPOCH, + lastSeenAt = Instant.EPOCH, + note = note, + membershipPeriods = emptyList(), + currentName = "Alice", + customName = customName, + ) +} diff --git a/app/src/test/java/dev/gf2log/app/management/MemberSearchTest.kt b/app/src/test/java/dev/gf2log/app/management/MemberSearchTest.kt new file mode 100644 index 0000000..6d93f03 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/MemberSearchTest.kt @@ -0,0 +1,47 @@ +package dev.gf2log.app.management + +import java.time.Instant +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MemberSearchTest { + private val nicknamed = MemberStatus( + uid = 123_456_789, + name = "Boss", + level = 60, + isActive = true, + firstSeenAt = Instant.EPOCH, + lastSeenAt = Instant.EPOCH, + note = "", + membershipPeriods = emptyList(), + currentName = "Kai", + customName = "Boss", + ) + + @Test + fun `a nicknamed member is found by the in-game name too`() { + assertTrue(nicknamed.matchesSearch("boss")) + assertTrue(nicknamed.matchesSearch("kai")) + assertFalse(nicknamed.matchesSearch("zed")) + } + + @Test + fun `a UID matches as shown with its hash and surrounding whitespace`() { + assertTrue(nicknamed.matchesSearch("#123456789")) + assertTrue(nicknamed.matchesSearch(" #1234 ")) + assertTrue(nicknamed.matchesSearch("# 1234")) + assertTrue(nicknamed.matchesSearch("6789")) + } + + @Test + fun `a bare hash or a non-numeric query never matches through the UID`() { + assertFalse(nicknamed.matchesSearch("#")) + assertFalse(nicknamed.matchesSearch("#12a")) + } + + @Test + fun `an empty query lists every member`() { + assertTrue(nicknamed.matchesSearch(" ")) + } +} From 8bd1daf34c03c261066e53c3e196f5e2e2a372e7 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:17:05 +0900 Subject: [PATCH 47/98] Store activity-dated boundaries as ACTIVITY and let Updates replace them A 21935 activity may date a roster-inferred join or withdrawal only inside MembershipCorrelationWindow on both correlation paths, and a fact already resolved to another UID never dates a boundary. The reconcile path dates a withdrawal by the latest admitted fact and a join by the earliest. Such boundaries are stored and labelled as their own ACTIVITY evidence (schema v14) instead of exact Updates evidence, stay editable, and a later 21960 boundary for the same change folds into the same period, within 48 hours or across any distance as long as no roster was captured between the two. Superseded events detach their activity facts. A roster omission closes every open period after folding weak duplicate open periods; the v14 upgrade marks existing activity-dated boundaries and folds duplicate open periods written by 2.7.1. Findings: q-data-1, q-data-m1, data-doc-contracts-m2, q-data-2, data-doc-contracts-3, data-doc-contracts-1, data-doc-contracts-m1 --- .../dev/gf2log/app/MemberDetailActivity.kt | 1 + .../app/management/ActivityIngestion.kt | 37 +++- .../management/ExactBoundaryCorrelation.kt | 78 ++++++-- .../management/MembershipCorrelationWindow.kt | 11 ++ .../app/management/MembershipHistory.kt | 18 +- .../gf2log/app/management/MembershipRows.kt | 168 +++++++++++++++++- .../management/PlatoonDatabaseMigrations.kt | 63 +++++++ .../gf2log/app/management/PlatoonModels.kt | 7 + .../gf2log/app/management/PlatoonSchema.kt | 3 +- .../app/management/SnapshotIngestion.kt | 17 +- .../app/management/WeeklyReportBuilder.kt | 4 +- app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../MembershipCorrelationWindowTest.kt | 20 +++ .../MembershipEventPresentationTest.kt | 35 +++- 15 files changed, 435 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index b56c264..5b1c680 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -442,6 +442,7 @@ class MemberDetailActivity : BaseActivity() { EvidenceSource.SNAPSHOT -> R.string.source_roster EvidenceSource.MANUAL -> R.string.source_manual EvidenceSource.LEGACY_IMPORT -> R.string.source_legacy + EvidenceSource.ACTIVITY -> R.string.source_activity }, ) 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 b2ad4c7..823b329 100644 --- a/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt +++ b/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt @@ -231,12 +231,15 @@ internal object ActivityIngestion { nameIsUnique: Boolean, ) { if (!nameIsUnique || from == null) return + // The same window as the reconcile path: after the previous roster, no later than the + // revealing one, and at most MAX_LEAD_MILLIS before it. val candidates = readMembershipActivityCandidates( db = db, + uid = uid, memberName = memberName, from = from, until = observedAt, - ) + ).filter { MembershipCorrelationWindow.admits(it.occurredAt, observedAt, from) } val occurredAt = candidates.map(ActivityCandidate::occurredAt).distinct().singleOrNull() ?: return applyExactMembershipBoundary( @@ -248,12 +251,15 @@ internal object ActivityIngestion { boundary = boundary, occurredAt = occurredAt, observedAt = observedAt, + source = EvidenceSource.ACTIVITY, activityIds = candidates.map(ActivityCandidate::id), ) } + /** Name-matched facts; one already resolved to another UID can never date [uid]'s boundary. */ private fun readMembershipActivityCandidates( db: SQLiteDatabase, + uid: Long, memberName: String, from: Instant, until: Instant, @@ -262,6 +268,7 @@ internal object ActivityIngestion { SELECT id, occurred_at FROM platoon_activity WHERE member_name = ? COLLATE BINARY + AND (resolved_uid IS NULL OR resolved_uid = ?) AND occurred_at > ? AND occurred_at <= ? AND action_id NOT IN (?, ?) @@ -270,6 +277,7 @@ internal object ActivityIngestion { """.trimIndent(), arrayOf( memberName, + uid.toString(), from.toEpochMilli().toString(), until.toEpochMilli().toString(), DAILY_PATROL_REWARD_ACTION_ID.toString(), @@ -284,10 +292,12 @@ internal object ActivityIngestion { } /** - * Upgrades an inferred roster boundary to an exact one when a single linked activity fact - * 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. + * Dates an inferred roster boundary from the UID-resolved activity facts within its + * [MembershipCorrelationWindow]: after the previous roster and no later than the roster that + * revealed the change. A fact that two of the member's inferred boundaries could both claim + * dates neither. Of the remaining facts, see [MembershipCorrelationWindow.datingInstant]. + * Candidate boundaries are loaded once per ingest; an applied boundary becomes + * activity-dated and therefore leaves the candidate set. */ private fun reconcileInferredMembershipBoundaries(db: SQLiteDatabase) { val boundariesByUid = readInferredBoundaries(db) @@ -325,6 +335,7 @@ internal object ActivityIngestion { } } } + val admittedByBoundary = mutableMapOf>() activities.forEach { activity -> val candidates = boundariesByUid[activity.uid] ?: return@forEach val boundary = candidates.singleOrNull { candidate -> @@ -334,6 +345,14 @@ internal object ActivityIngestion { previousRosterAt = candidate.previousRosterAt, ) } ?: return@forEach + admittedByBoundary.getOrPut(boundary, ::mutableListOf) += activity + } + admittedByBoundary.forEach { (boundary, admitted) -> + val occurredAt = MembershipCorrelationWindow.datingInstant( + boundary = boundary.boundary, + admitted = admitted.map(ResolvedActivityCandidate::occurredAt), + ) ?: return@forEach + val dating = admitted.filter { it.occurredAt == occurredAt } val eventType = when (boundary.boundary) { MembershipBoundary.WITHDRAW -> MemberEventType.LEFT MembershipBoundary.JOIN -> @@ -343,14 +362,14 @@ internal object ActivityIngestion { db = db, membershipPeriodId = boundary.membershipPeriodId, uid = boundary.uid, - memberName = activity.memberName, + memberName = dating.first().memberName, type = eventType, boundary = boundary.boundary, - occurredAt = activity.occurredAt, + occurredAt = occurredAt, observedAt = boundary.inferredAt, - activityIds = listOf(activity.id), + source = EvidenceSource.ACTIVITY, + activityIds = dating.map(ResolvedActivityCandidate::id), ) - candidates.remove(boundary) } } diff --git a/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt b/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt index c97e864..bf896fa 100644 --- a/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt +++ b/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt @@ -47,6 +47,8 @@ internal object ExactBoundaryCorrelation { ) { membershipEvents += 1 } + // The exact boundary supersedes a weak open period for the same change. + MembershipRows.foldDuplicateOpenPeriods(db, member.uid) synchronizeCurrentMembershipFromUpdate(db, member.uid) } } else if (effect == PlatoonUpdateEffect.DAILY_PATROL) { @@ -285,7 +287,7 @@ internal object ExactBoundaryCorrelation { ?: findOrCreateUpdateMembershipPeriod(db, member.uid, boundary, occurredAt) correlatableCandidates.forEach { candidate -> - db.delete("member_events", "id = ?", arrayOf(candidate.id.toString())) + MembershipRows.deleteSupersededEvents(db, "id = ?", arrayOf(candidate.id.toString())) } correlatableCandidates.mapNotNull(BoundaryEventCandidate::membershipPeriodId) .filter { it != membershipPeriodId } @@ -313,6 +315,7 @@ internal object ExactBoundaryCorrelation { boundary = boundary, occurredAt = occurredAt, observedAt = observedAt, + source = EvidenceSource.GAME_UPDATES, activityIds = emptyList(), ) if (boundary == MembershipBoundary.WITHDRAW && @@ -415,9 +418,10 @@ internal object ExactBoundaryCorrelation { AND ( joined_at IS NULL OR ( - joined_precision != ? + (joined_precision != ? OR joined_source = ?) AND ABS(joined_at - ?) <= ? ) + OR ${weakBoundaryInSameRosterGap(MembershipBoundary.JOIN)} ) ORDER BY CASE WHEN left_at IS NULL THEN 0 ELSE 1 END, @@ -430,8 +434,10 @@ internal object ExactBoundaryCorrelation { uid.toString(), occurredAtMillis.toString(), EvidencePrecision.EXACT.name, + EvidenceSource.ACTIVITY.name, occurredAtMillis.toString(), EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), + *weakBoundaryInSameRosterGapArguments(occurredAtMillis), occurredAtMillis.toString(), occurredAtMillis.toString(), ), @@ -445,9 +451,10 @@ internal object ExactBoundaryCorrelation { AND ( left_at IS NULL OR ( - left_precision != ? + (left_precision != ? OR left_source = ?) AND ABS(left_at - ?) <= ? ) + OR ${weakBoundaryInSameRosterGap(MembershipBoundary.WITHDRAW)} ) ORDER BY CASE WHEN left_at IS NULL THEN 0 ELSE 1 END, @@ -461,8 +468,10 @@ internal object ExactBoundaryCorrelation { uid.toString(), occurredAtMillis.toString(), EvidencePrecision.EXACT.name, + EvidenceSource.ACTIVITY.name, occurredAtMillis.toString(), EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), + *weakBoundaryInSameRosterGapArguments(occurredAtMillis), occurredAtMillis.toString(), occurredAtMillis.toString(), ), @@ -482,6 +491,41 @@ internal object ExactBoundaryCorrelation { ) } + /** + * A roster-inferred or activity-dated [boundary] that records the same change as an Updates + * boundary at the bound instant, however late the Updates feed arrives: no roster was + * captured between the two instants, so the first roster after the real change revealed it. + * An inferred boundary sits on that revealing roster, so it cannot precede the change. Bind + * [weakBoundaryInSameRosterGapArguments] in place. + */ + private fun weakBoundaryInSameRosterGap(boundary: MembershipBoundary): String { + val at = boundary.atColumn + return """ + ( + $at IS NOT NULL + AND ( + (${boundary.sourceColumn} IN (?, ?) AND ${boundary.precisionColumn} = ? AND $at >= ?) + OR ${boundary.sourceColumn} = ? + ) + AND NOT EXISTS ( + SELECT 1 FROM snapshots roster + WHERE roster.captured_at >= MIN($at, ?) + AND roster.captured_at < MAX($at, ?) + ) + ) + """.trimIndent() + } + + private fun weakBoundaryInSameRosterGapArguments(occurredAtMillis: Long): Array = arrayOf( + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + EvidencePrecision.INFERRED.name, + occurredAtMillis.toString(), + EvidenceSource.ACTIVITY.name, + occurredAtMillis.toString(), + occurredAtMillis.toString(), + ) + private fun isActiveMember(db: SQLiteDatabase, uid: Long): Boolean = db.rawQuery( "SELECT is_active FROM members WHERE uid = ? LIMIT 1", @@ -528,7 +572,7 @@ internal object ExactBoundaryCorrelation { ${opposite}_precision, ${opposite}_source FROM membership_periods WHERE id = ? - AND ${boundary.precisionColumn} IN (?, ?) + AND (${boundary.precisionColumn} IN (?, ?) OR ${boundary.sourceColumn} = ?) AND ( ${boundary.atColumn} IS NULL OR ABS(${boundary.atColumn} - ?) <= ? @@ -539,6 +583,7 @@ internal object ExactBoundaryCorrelation { shadowMembershipPeriodId.toString(), EvidencePrecision.INFERRED.name, EvidencePrecision.UNKNOWN.name, + EvidenceSource.ACTIVITY.name, occurredAt.toEpochMilli().toString(), EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), ), @@ -587,10 +632,20 @@ internal object ExactBoundaryCorrelation { ), ) } - db.delete("member_events", "membership_period_id = ?", arrayOf(shadowMembershipPeriodId.toString())) + MembershipRows.deleteSupersededEvents( + db, + "membership_period_id = ?", + arrayOf(shadowMembershipPeriodId.toString()), + ) db.delete("membership_periods", "id = ?", arrayOf(shadowMembershipPeriodId.toString())) } + /** + * Dates [boundary] of a period at the instant [occurredAt] and replaces the weaker roster or + * activity event that recorded it. [source] is [EvidenceSource.GAME_UPDATES] only for a + * 21960 boundary; an activity-dated roster boundary is [EvidenceSource.ACTIVITY], so it is + * never presented as Updates evidence and a later Updates boundary still replaces it. + */ fun applyExactMembershipBoundary( db: SQLiteDatabase, membershipPeriodId: Long, @@ -600,24 +655,27 @@ internal object ExactBoundaryCorrelation { boundary: MembershipBoundary, occurredAt: Instant, observedAt: Instant, + source: EvidenceSource, activityIds: List, ) { + require(source == EvidenceSource.GAME_UPDATES || source == EvidenceSource.ACTIVITY) val values = ContentValues().apply { put(boundary.atColumn, occurredAt.toEpochMilli()) putNull("${boundary.prefix}_date") put("${boundary.prefix}_time_known", 1) put(boundary.precisionColumn, EvidencePrecision.EXACT.name) - put(boundary.sourceColumn, EvidenceSource.GAME_UPDATES.name) + put(boundary.sourceColumn, source.name) } db.update("membership_periods", values, "id = ?", arrayOf(membershipPeriodId.toString())) val inferredTypes = boundary.eventTypes.map(MemberEventType::name) - db.delete( - "member_events", - "membership_period_id = ? AND source IN (?, ?) AND event_type IN (?, ?)", + MembershipRows.deleteSupersededEvents( + db, + "membership_period_id = ? AND source IN (?, ?, ?) AND event_type IN (?, ?)", arrayOf( membershipPeriodId.toString(), EvidenceSource.SNAPSHOT.name, EvidenceSource.LEGACY_IMPORT.name, + EvidenceSource.ACTIVITY.name, inferredTypes[0], inferredTypes[1], ), @@ -629,7 +687,7 @@ internal object ExactBoundaryCorrelation { occurredAt = occurredAt, observedAt = observedAt, precision = EvidencePrecision.EXACT, - source = EvidenceSource.GAME_UPDATES, + source = source, note = memberName, membershipPeriodId = membershipPeriodId, ) diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipCorrelationWindow.kt b/app/src/main/java/dev/gf2log/app/management/MembershipCorrelationWindow.kt index 15e0fc3..ca89ca9 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipCorrelationWindow.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipCorrelationWindow.kt @@ -22,4 +22,15 @@ internal object MembershipCorrelationWindow { occurredAt.isAfter(previousRosterAt) && !occurredAt.isAfter(inferredAt) && !occurredAt.isBefore(inferredAt.minusMillis(MAX_LEAD_MILLIS)) + + /** + * The instant that dates [boundary] from the [admitted] activity instants: the earliest for a + * join, since the member acted only once joined, and the latest for a withdrawal, since the + * member acted only before leaving. Either is the tightest bound the facts prove. + */ + fun datingInstant(boundary: MembershipBoundary, admitted: Collection): Instant? = + when (boundary) { + MembershipBoundary.JOIN -> admitted.minOrNull() + MembershipBoundary.WITHDRAW -> admitted.maxOrNull() + } } diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt b/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt index 54705b0..af7eeb1 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt @@ -27,9 +27,19 @@ fun isValidMembershipRange( else -> !withdrew.instant.isBefore(joined.instant) } +/** + * Only 21960 Updates boundaries are immutable. An [EvidenceSource.ACTIVITY] boundary is an + * inference from a name-matched activity, so it stays editable like a roster observation. + */ fun EvidenceSource.isImmutableMembershipBoundary(): Boolean = this == EvidenceSource.GAME_UPDATES +/** + * Sources whose boundary events carry the instant the change happened, so the weekly roster + * applies them at that instant: exact Updates and activity-dated roster boundaries. + */ +internal val TIMED_BOUNDARY_SOURCES = setOf(EvidenceSource.GAME_UPDATES, EvidenceSource.ACTIVITY) + /** * Only a recorded packet-linked boundary is immutable. A period opened by an exact Updates * withdrawal has a GAME_UPDATES join source but no join time, and that unknown side stays @@ -99,6 +109,7 @@ object MembershipEventPresentation { EvidenceSource.LEGACY_IMPORT, EvidenceSource.GAME_UPDATES, EvidenceSource.MANUAL, + EvidenceSource.ACTIVITY, ) fun deduplicate(events: List): List { @@ -133,7 +144,7 @@ object MembershipEventPresentation { } fun timePrefix(event: MemberEvent, zoneId: ZoneId): String? = when { - event.source == EvidenceSource.GAME_UPDATES && event.occurredAt != null -> + event.source in TIMED_BOUNDARY_SOURCES && event.occurredAt != null -> eventTime.format(event.occurredAt.atZone(zoneId)) event.source == EvidenceSource.MANUAL && !event.timeKnown -> "??:??" event.source == EvidenceSource.MANUAL && event.occurredAt != null -> @@ -148,8 +159,9 @@ object MembershipEventPresentation { event.type in displayedTypes && event.source in displayedSources private fun evidencePriority(source: EvidenceSource): Int = when (source) { - EvidenceSource.GAME_UPDATES -> 3 - EvidenceSource.MANUAL -> 2 + EvidenceSource.GAME_UPDATES -> 4 + EvidenceSource.MANUAL -> 3 + EvidenceSource.ACTIVITY -> 2 EvidenceSource.SNAPSHOT, EvidenceSource.LEGACY_IMPORT -> 1 } 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 359df7d..0df4002 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt @@ -53,18 +53,69 @@ internal object MembershipRows { }, ) + /** + * Closes the member's open membership at a roster-observed withdrawal [leftAt] and returns + * the newest open period, whose boundary event the caller records. Weak duplicate open + * periods are folded first, and any other open period is closed at the same boundary with + * its own event, so a roster omission never leaves an open period behind. + */ fun closeLatestMembershipPeriod( db: SQLiteDatabase, uid: Long, + memberName: String, leftAt: Instant, precision: EvidencePrecision, source: EvidenceSource, ): Long { + foldDuplicateOpenPeriods(db, uid) val membershipPeriodId = db.rawQuery( "SELECT id FROM membership_periods WHERE uid = ? AND left_at IS NULL ORDER BY id DESC LIMIT 1", arrayOf(uid.toString()), ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } ?: error("Active member $uid has no open membership period") + closeMembershipPeriod(db, membershipPeriodId, leftAt, precision, source) + closeOpenMembershipPeriods(db, uid, memberName, leftAt, precision, source) + return membershipPeriodId + } + + /** Closes every open period of [uid] that began by [leftAt], each with its own LEFT event. */ + fun closeOpenMembershipPeriods( + db: SQLiteDatabase, + uid: Long, + memberName: String, + leftAt: Instant, + precision: EvidencePrecision, + source: EvidenceSource, + ) { + openMembershipPeriodIds(db, uid, leftAt).forEach { membershipPeriodId -> + closeMembershipPeriod(db, membershipPeriodId, leftAt, precision, source) + insertSnapshotBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriodId, + uid = uid, + memberName = memberName, + type = MemberEventType.LEFT, + observedAt = leftAt, + source = source, + ) + } + } + + /** Open periods, newest first, that a withdrawal at [leftAt] can close. */ + private fun openMembershipPeriodIds(db: SQLiteDatabase, uid: Long, leftAt: Instant): List = + db.rawQuery( + "SELECT id FROM membership_periods WHERE uid = ? AND left_at IS NULL " + + "AND (joined_at IS NULL OR joined_at <= ?) ORDER BY id DESC", + arrayOf(uid.toString(), leftAt.toEpochMilli().toString()), + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getLong(0)) } } + + private fun closeMembershipPeriod( + db: SQLiteDatabase, + membershipPeriodId: Long, + leftAt: Instant, + precision: EvidencePrecision, + source: EvidenceSource, + ) { db.update( "membership_periods", ContentValues().apply { @@ -77,7 +128,107 @@ internal object MembershipRows { "id = ?", arrayOf(membershipPeriodId.toString()), ) - return membershipPeriodId + } + + /** + * Two open periods claim the same current membership, which no writer may leave behind. + * Keeps the open period with the strongest join (exact Updates, then manual, then activity + * or roster evidence) and drops each other open period whose join rests only on roster or + * activity evidence, unless a roster between the two joins omitted the member: then both + * are real and stay for the caller. A dropped period's note moves to the kept one; if both + * notes cannot fit one note, nothing is dropped. Returns how many periods were dropped. + */ + fun foldDuplicateOpenPeriods(db: SQLiteDatabase, uid: Long): Int { + val open = db.rawQuery( + "SELECT id, joined_at, joined_precision, joined_source, note " + + "FROM membership_periods WHERE uid = ? AND left_at IS NULL", + arrayOf(uid.toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + OpenPeriod( + id = cursor.getLong(0), + joinedAt = cursor.getNullableLong(1), + precision = EvidencePrecision.valueOf(cursor.getString(2)), + source = EvidenceSource.valueOf(cursor.getString(3)), + note = cursor.getString(4), + ), + ) + } + } + } + if (open.size < 2) return 0 + val kept = open.sortedWith(compareBy { it.joinStrength }.thenByDescending { it.id }).first() + var keptNote = kept.note + var dropped = 0 + open.filter { it.id != kept.id && it.source in DUPLICATE_OPEN_PERIOD_SOURCES }.forEach { duplicate -> + if (rosterOmittedBetween(db, uid, duplicate.joinedAt, kept.joinedAt)) return@forEach + val mergedNote = listOf(keptNote, duplicate.note).filter(String::isNotBlank).joinToString("\n") + if (mergedNote.length > ManualTextPolicy.MAX_NOTE_CHARS) return@forEach + keptNote = mergedNote + val period = arrayOf(duplicate.id.toString()) + deleteSupersededEvents(db, "membership_period_id = ?", period) + db.delete("membership_periods", "id = ?", period) + dropped += 1 + } + if (keptNote != kept.note) { + db.update( + "membership_periods", + ContentValues().apply { put("note", keptNote) }, + "id = ?", + arrayOf(kept.id.toString()), + ) + } + return dropped + } + + /** Whether a roster captured strictly between the two joins lacked [uid]. */ + private fun rosterOmittedBetween(db: SQLiteDatabase, uid: Long, first: Long?, second: Long?): Boolean { + if (first == null || second == null || first == second) return false + return db.exists( + """ + SELECT 1 FROM snapshots roster + WHERE roster.captured_at > ? AND roster.captured_at < ? + AND NOT EXISTS ( + SELECT 1 FROM snapshot_members member + WHERE member.snapshot_id = roster.id AND member.uid = ? + ) + LIMIT 1 + """.trimIndent(), + minOf(first, second), + maxOf(first, second), + uid, + ) + } + + /** Every uid with more than one open period; the one-time v14 repair folds each of them. */ + fun foldAllDuplicateOpenPeriods(db: SQLiteDatabase) { + db.rawQuery( + "SELECT uid FROM membership_periods WHERE left_at IS NULL GROUP BY uid HAVING COUNT(*) > 1", + null, + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getLong(0)) } } + .forEach { uid -> foldDuplicateOpenPeriods(db, uid) } + } + + /** Joins that only roster or activity evidence supports; a duplicate of these is dropped. */ + private val DUPLICATE_OPEN_PERIOD_SOURCES = SNAPSHOT_EVENT_SOURCES + EvidenceSource.ACTIVITY + + private data class OpenPeriod( + val id: Long, + val joinedAt: Long?, + val precision: EvidencePrecision, + val source: EvidenceSource, + val note: String, + ) { + val joinStrength: Int + get() = when { + source == EvidenceSource.GAME_UPDATES && precision == EvidencePrecision.EXACT -> 0 + source == EvidenceSource.MANUAL -> 1 + source == EvidenceSource.ACTIVITY -> 2 + joinedAt != null -> 3 + else -> 4 + } } fun ensureRosterActiveMembershipPeriod( @@ -208,6 +359,21 @@ internal object MembershipRows { db.delete("member_events", selection, arguments) } + /** + * [deleteEvents] for boundary events that a stronger boundary replaced. Activity facts that + * dated them return to plain name-resolved history instead of claiming a boundary that no + * longer exists, and stay eligible to date another inferred boundary. + */ + fun deleteSupersededEvents(db: SQLiteDatabase, selection: String, arguments: Array) { + db.update( + "platoon_activity", + ContentValues().apply { put("resolution", ActivityResolution.UNIQUE_ROSTER_NAME.name) }, + "resolution = ? AND member_event_id IN (SELECT id FROM member_events WHERE $selection)", + arrayOf(ActivityResolution.MEMBERSHIP_CORRELATION.name, *arguments), + ) + deleteEvents(db, selection, arguments) + } + /** Deletes one period with its events; returns whether the period row existed. */ fun deleteMembershipPeriod(db: SQLiteDatabase, membershipPeriodId: Long): Boolean { val id = arrayOf(membershipPeriodId.toString()) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt index f70a7ec..d6277f4 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt @@ -32,6 +32,69 @@ internal object PlatoonDatabaseMigrations { } if (oldVersion < 12) PlatoonDatabaseSchema.createWeeklyReportHistoryTables(db) if (oldVersion < 13) MembershipRows.synchronizeAllActiveStates(db) + if (oldVersion < 14) { + markActivityDatedBoundaries(db) + MembershipRows.foldAllDuplicateOpenPeriods(db) + MembershipRows.synchronizeAllActiveStates(db) + } + } + + /** + * Before v14 a roster boundary dated by a 21935 activity was stored as exact Updates + * evidence. Its event is the one an activity fact cites, since only activity dating links + * facts to events; it and the period side it dated become [EvidenceSource.ACTIVITY], so a + * later 21960 boundary replaces them. A fact evicted by the activity cap cannot be told + * apart and keeps its stored source. + */ + private fun markActivityDatedBoundaries(db: SQLiteDatabase) { + db.execSQL( + """ + UPDATE member_events SET source = ? + WHERE source = ? + AND id IN ( + SELECT member_event_id FROM platoon_activity + WHERE resolution = ? AND member_event_id IS NOT NULL + ) + """.trimIndent(), + arrayOf( + EvidenceSource.ACTIVITY.name, + EvidenceSource.GAME_UPDATES.name, + ActivityResolution.MEMBERSHIP_CORRELATION.name, + ), + ) + MembershipBoundary.entries.forEach { boundary -> + val types = boundary.eventTypes.map(MemberEventType::name) + db.execSQL( + """ + UPDATE membership_periods SET ${boundary.sourceColumn} = ? + WHERE ${boundary.sourceColumn} = ? + AND EXISTS ( + SELECT 1 FROM member_events event + WHERE event.membership_period_id = membership_periods.id + AND event.source = ? + AND event.event_type IN (?, ?) + AND event.occurred_at = membership_periods.${boundary.atColumn} + ) + AND NOT EXISTS ( + SELECT 1 FROM member_events event + WHERE event.membership_period_id = membership_periods.id + AND event.source = ? + AND event.event_type IN (?, ?) + AND event.occurred_at = membership_periods.${boundary.atColumn} + ) + """.trimIndent(), + arrayOf( + EvidenceSource.ACTIVITY.name, + EvidenceSource.GAME_UPDATES.name, + EvidenceSource.ACTIVITY.name, + types[0], + types[1], + EvidenceSource.GAME_UPDATES.name, + types[0], + types[1], + ), + ) + } } private fun renameTenuresToMembershipPeriods(db: SQLiteDatabase, oldVersion: Int) { 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 3c9912a..33703f5 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt @@ -144,11 +144,18 @@ enum class EvidencePrecision { MANUAL, } +/** + * Who supplied a membership boundary. Stored by name and encoded by ordinal in weekly history, + * so new values are only appended. Only 21960 Updates evidence is [GAME_UPDATES]; a roster + * boundary dated by a name-matched 21935 activity is [ACTIVITY] (schema v14), which a later + * Updates boundary for the same change replaces. + */ enum class EvidenceSource { SNAPSHOT, GAME_UPDATES, MANUAL, LEGACY_IMPORT, + ACTIVITY, } enum class DailyEvidence { diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonSchema.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonSchema.kt index 3b827fc..2872a01 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonSchema.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonSchema.kt @@ -5,7 +5,8 @@ package dev.gf2log.app.management */ internal object PlatoonSchema { const val DATABASE_NAME = "platoon.db" - const val CURRENT_VERSION = 13 + /** v14 stores activity-dated boundaries as [EvidenceSource.ACTIVITY]; DDL is unchanged. */ + const val CURRENT_VERSION = 14 const val MIN_BACKUP_VERSION = 1 private val baseTables = setOf( diff --git a/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt index e2b4c55..d7c7cbd 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt @@ -226,15 +226,28 @@ internal object SnapshotIngestion { from = priorCapturedAt, observedAt = observedAt, ) - val membershipPeriodId = exactMembershipPeriodId ?: closeLatestMembershipPeriod( + if (exactMembershipPeriodId != null) { + // The exact withdrawal closed its period; the omission still ends any other one. + MembershipRows.closeOpenMembershipPeriods( + db = db, + uid = member.uid, + memberName = member.name, + leftAt = observedAt, + precision = EvidencePrecision.INFERRED, + source = source, + ) + recordLastSeen(db, member.uid, observedAt) + return + } + val membershipPeriodId = closeLatestMembershipPeriod( db = db, uid = member.uid, + memberName = member.name, leftAt = observedAt, precision = EvidencePrecision.INFERRED, source = source, ) recordLastSeen(db, member.uid, observedAt) - if (exactMembershipPeriodId != null) return insertSnapshotBoundaryEvent( db, membershipPeriodId, 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 4583128..f69ae0a 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt @@ -383,7 +383,7 @@ object WeeklyReportBuilder { } membershipEvents.asSequence() .filter { event -> - event.source == EvidenceSource.GAME_UPDATES && + event.source in TIMED_BOUNDARY_SOURCES && event.precision == EvidencePrecision.EXACT && event.occurredAt?.isBefore(rosterCutoff) == true } @@ -756,7 +756,7 @@ object WeeklyReportBuilder { ): Set = snapshotRoster.toMutableSet().apply { events.asSequence() .filter { event -> - event.source == EvidenceSource.GAME_UPDATES && + event.source in TIMED_BOUNDARY_SOURCES && event.precision == EvidencePrecision.EXACT && event.occurredAt?.let { occurredAt -> occurredAt.isBefore(cutoff) && diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 19c8f8d..6c7e86f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -438,6 +438,7 @@ 수동 명단 관측 정확 (동향) + 활동 기준 가입 중 최근 스냅샷 탈퇴 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7e19a9a..77bdaea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -442,6 +442,7 @@ manual observed in roster exact (Updates) + dated by activity Active Last snapshot Withdrawn diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipCorrelationWindowTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipCorrelationWindowTest.kt index 11d940b..b5ad639 100644 --- a/app/src/test/java/dev/gf2log/app/management/MembershipCorrelationWindowTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/MembershipCorrelationWindowTest.kt @@ -1,7 +1,9 @@ package dev.gf2log.app.management import java.time.Instant +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -48,6 +50,24 @@ class MembershipCorrelationWindowTest { assertFalse(MembershipCorrelationWindow.admits(revealingRoster, revealingRoster, null)) } + @Test + fun `a join is dated by the earliest admitted fact and a withdrawal by the latest`() { + // Previous roster 06:00, actions at 10:00 and 18:00, revealing roster 21:00. + val donation = Instant.parse("2026-08-03T10:00:00Z") + val laterAction = Instant.parse("2026-08-03T18:00:00Z") + val admitted = listOf(laterAction, donation) + + assertEquals( + donation, + MembershipCorrelationWindow.datingInstant(MembershipBoundary.JOIN, admitted), + ) + assertEquals( + laterAction, + MembershipCorrelationWindow.datingInstant(MembershipBoundary.WITHDRAW, admitted), + ) + assertNull(MembershipCorrelationWindow.datingInstant(MembershipBoundary.WITHDRAW, emptyList())) + } + private fun admits(occurredAt: Instant) = MembershipCorrelationWindow.admits(occurredAt, revealingRoster, previousRoster) } diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipEventPresentationTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipEventPresentationTest.kt index 2327e1f..194fc7e 100644 --- a/app/src/test/java/dev/gf2log/app/management/MembershipEventPresentationTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/MembershipEventPresentationTest.kt @@ -4,6 +4,8 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class MembershipEventPresentationTest { @@ -104,6 +106,37 @@ class MembershipEventPresentationTest { ) } + @Test + fun `an activity-dated boundary shows its time and yields to the exact Updates event`() { + val activityDated = event( + id = 1, + source = EvidenceSource.ACTIVITY, + occurredAt = Instant.parse("2026-07-29T05:00:00Z"), + eventDate = null, + timeKnown = true, + type = MemberEventType.JOINED, + ) + val exact = event( + id = 2, + source = EvidenceSource.GAME_UPDATES, + occurredAt = Instant.parse("2026-07-29T01:00:00Z"), + eventDate = null, + timeKnown = true, + type = MemberEventType.JOINED, + ) + + assertEquals("14:00", MembershipEventPresentation.timePrefix(activityDated, zone)) + assertEquals(listOf(activityDated), MembershipEventPresentation.deduplicate(listOf(activityDated))) + assertEquals(listOf(exact), MembershipEventPresentation.deduplicate(listOf(activityDated, exact))) + } + + @Test + fun `only Updates boundaries are immutable`() { + assertTrue(EvidenceSource.GAME_UPDATES.isImmutableMembershipBoundary()) + assertFalse(EvidenceSource.ACTIVITY.isImmutableMembershipBoundary()) + assertEquals(setOf(EvidenceSource.GAME_UPDATES, EvidenceSource.ACTIVITY), TIMED_BOUNDARY_SOURCES) + } + private fun event( id: Long, source: EvidenceSource, @@ -119,7 +152,7 @@ class MembershipEventPresentationTest { eventDate = eventDate, timeKnown = timeKnown, observedAt = occurredAt ?: Instant.parse("2026-07-29T00:00:00Z"), - precision = if (source == EvidenceSource.GAME_UPDATES) { + precision = if (source in TIMED_BOUNDARY_SOURCES) { EvidencePrecision.EXACT } else { EvidencePrecision.INFERRED From 8341837757c394ed9cda59fb89c7cc5dcf638409 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:17:19 +0900 Subject: [PATCH 48/98] Rebuild restored weekly history recorded under another reset zone The database now records the reset zone its weekly history is bucketed in. After a restore commits, history recorded under a different effective zone, or before the zone was recorded, is rebuilt from the evidence like a timezone change; history in the same zone keeps its revisions and selection. Finding: q-time-boundaries-3 --- .../PlatoonBackupManagerIntegrationTest.kt | 74 +++++++++++++++++++ .../app/management/PlatoonBackupManager.kt | 3 + .../gf2log/app/management/PlatoonDatabase.kt | 12 ++- .../app/management/PlatoonEvidenceDigest.kt | 15 +++- .../app/management/PlatoonRepository.kt | 30 +++++++- .../app/management/WeeklyHistoryStore.kt | 48 +++++++++++- docs/PLATOON_MANAGEMENT.md | 4 + 7 files changed, 177 insertions(+), 9 deletions(-) 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 3b6e3f3..0d4b151 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -557,6 +557,80 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(CsvImportCheckpointManager(context, storageScope).canUndo()) } + @Test + fun platoonOnlyRestoreRebucketsWeeklyHistoryInTheTargetsResetZone() { + val week = LocalDate.of(2026, 7, 26) + val repository = seedWeeklyHistory(week) + val archive = ByteArrayOutputStream().also { + PlatoonBackupManager(context, storageScope).export(it) + }.toByteArray() + // The archive's history is bucketed at Korea's 05:00; the target resets at 05:00 UTC. + GameTimeZonePreferences.setRegion(context, GameServerRegion.HAOPLAY_GLOBAL, storageScope.storageId) + val archived = storedReports(repository, week) + + PlatoonBackupManager(context, storageScope).restore(ByteArrayInputStream(archive)) + + val utc = java.time.ZoneOffset.UTC + val restored = PlatoonRepository(context, storageScope) + assertEquals(utc, restored.gameZone()) + val entries = restored.listWeeklyReportHistory(week) + assertTrue(entries.isNotEmpty()) + assertTrue(entries.none { it.active }) + val live = liveReport(restored, week, utc) + // The rosters fall on different game days at the two resets, so the revisions differ. + assertTrue(archived.none { it == live }) + assertEquals(List(entries.size) { live }, storedReports(restored, week)) + } + + @Test + fun platoonOnlyRestoreInTheSameResetZoneKeepsWeeklyHistory() { + val week = LocalDate.of(2026, 7, 26) + val repository = seedWeeklyHistory(week) + val archive = ByteArrayOutputStream().also { + PlatoonBackupManager(context, storageScope).export(it) + }.toByteArray() + val before = repository.listWeeklyReportHistory(week) + + PlatoonBackupManager(context, storageScope).restore(ByteArrayInputStream(archive)) + + assertEquals(before, PlatoonRepository(context, storageScope).listWeeklyReportHistory(week)) + } + + /** Two near-reset rosters whose game days differ between Korea and UTC, plus a selection. */ + private fun seedWeeklyHistory(week: LocalDate): PlatoonRepository { + val repository = PlatoonRepository(context, storageScope) + listOf("2026-07-28T20:30:00Z" to 0u, "2026-07-29T20:30:00Z" to 90u).forEach { (time, merit) -> + repository.ingest( + Instant.parse(time), + listOf( + GuildMember( + uid = CURRENT_UID.toUInt(), + name = "History member", + level = 1u, + weeklyMerit = merit, + totalMerit = 1_000u + merit, + highScore = 0u, + totalScore = 0u, + lastLogin = 0u, + ), + ), + "history-$merit.csv", + ) + } + val entries = repository.listWeeklyReportHistory(week) + assertTrue(repository.restoreWeeklyReportHistory(entries.last().id, week)) + return repository + } + + private fun storedReports(repository: PlatoonRepository, week: LocalDate) = + repository.listWeeklyReportHistory(week).map { repository.weeklyReportHistory(it.id, week) } + + /** The live revision as history stores it, so codec normalization does not differ. */ + private fun liveReport(repository: PlatoonRepository, week: LocalDate, zone: java.time.ZoneId) = + WeeklyReportHistoryCodec.decode( + WeeklyReportHistoryCodec.encode(repository.readWeeklyTableRevision(week, zone)).payload, + ) + @Test fun derivedWeeklyHistoryWrittenAfterACsvImportKeepsItsUndo() { seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") 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 dc90347..79be90a 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -304,6 +304,9 @@ class PlatoonBackupManager internal constructor( previousRetainedCsvDirectory, previousDatabaseFile(appContext, storageScope), ) + // Once committed, so constructing the repository cannot roll the restore back: + // derived weekly history follows the restored scope's effective reset zone. + PlatoonRepository(appContext, storageScope).reconcileWeeklyHistoryZone() } catch (error: Exception) { runCatching { recoverInterruptedFullRestore(appContext, settingsStore, storageScope) 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 3ad357d..91471e8 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -222,6 +222,7 @@ internal class PlatoonDatabase private constructor( fingerprint: String, payload: ByteArray, clearActiveOnChange: Boolean, + zoneId: ZoneId? = null, ): Long = WeeklyHistoryStore.record( writableDatabase, periodStartEpochDay, @@ -229,6 +230,7 @@ internal class PlatoonDatabase private constructor( fingerprint, payload, clearActiveOnChange, + zoneId, ) @Synchronized @@ -252,8 +254,14 @@ internal class PlatoonDatabase private constructor( WeeklyHistoryStore.clearActive(writableDatabase, periodStartEpochDay) @Synchronized - internal fun replaceWeeklyReportHistory(replacements: List) = - WeeklyHistoryStore.replace(writableDatabase, replacements) + internal fun replaceWeeklyReportHistory( + replacements: List, + zoneId: ZoneId? = null, + ) = WeeklyHistoryStore.replace(writableDatabase, replacements, zoneId) + + @Synchronized + internal fun isWeeklyHistoryBucketedIn(zoneId: ZoneId): Boolean = + WeeklyHistoryStore.isBucketedIn(writableDatabase, zoneId) companion object { /** A fresh current-schema database that never touches storage, for schema comparison. */ diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonEvidenceDigest.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonEvidenceDigest.kt index a3f8fde..258576c 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonEvidenceDigest.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonEvidenceDigest.kt @@ -9,14 +9,21 @@ import java.security.MessageDigest /** * Digests the primary Platoon evidence of a database for the CSV import undo guard. * - * Every table counts except the derived weekly history, its restored selection, and SQLite or - * Android bookkeeping, so recording or selecting a weekly revision never makes an otherwise valid + * Every table counts except the derived weekly history, its restored selection, and app, SQLite, + * or Android bookkeeping, so recording or selecting a weekly revision never makes an otherwise valid * undo look like newer work, while any evidence, membership, note, or manual-value change does. * Rows are hashed in content order, independent of where SQLite stores them. */ internal object PlatoonEvidenceDigest { - /** Derived tables that the undo checkpoint restores as well but that never hold evidence. */ - private val DERIVED_TABLES = setOf("weekly_report_history", "weekly_report_history_state") + /** + * Derived tables that the undo checkpoint restores as well but that never hold evidence. The + * maintenance state keeps bookkeeping only: the activity-resolution cursor and history zone. + */ + private val DERIVED_TABLES = setOf( + "weekly_report_history", + "weekly_report_history_state", + "platoon_maintenance_state", + ) /** Whether [table] holds evidence the digest covers. */ fun covers(table: String): Boolean = 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 3e02fab..9403d41 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -318,7 +318,7 @@ internal class PlatoonRepository( ): WeeklyTableRevision { val live = buildLiveWeeklyRevision(referenceDay, zoneId, asOf) try { - recordHistory(live, Instant.now(), clearActiveOnChange = false) + recordHistory(live, zoneId, Instant.now(), clearActiveOnChange = false) } catch (error: Exception) { Log.e(TAG, "Unable to record weekly history for ${live.report.periodStart}", error) } @@ -495,7 +495,30 @@ internal class PlatoonRepository( payload = encoded.payload, ) } - access { it.replaceWeeklyReportHistory(replacements) } + access { it.replaceWeeklyReportHistory(replacements, zoneId) } + } + } + + /** + * Rebuilds the derived weekly history unless it is known to be bucketed in this Platoon's + * reset zone, as after restoring an archive made under another zone or before the zone was + * kept. History is a derived audit trail, so a rebuild that fails clears it instead; live + * revisions are always recomputed from evidence. Never throws. Returns whether it rebuilt. + */ + fun reconcileWeeklyHistoryZone(): Boolean = withExclusiveDatabase(storageScope) { + try { + val zone = gameZone() + if (access { it.isWeeklyHistoryBucketedIn(zone) }) return@withExclusiveDatabase false + try { + rebuildWeeklyHistoryForTimeZoneChange(zone) + } catch (error: Exception) { + Log.e(TAG, "Unable to rebuild weekly history for $zone; clearing it", error) + access { it.replaceWeeklyReportHistory(emptyList(), zone) } + } + true + } catch (error: Exception) { + Log.e(TAG, "Unable to reconcile weekly history with its reset zone", error) + false } } @@ -600,6 +623,7 @@ internal class PlatoonRepository( private fun recordHistory( revision: WeeklyTableRevision, + zoneId: ZoneId, recordedAt: Instant, clearActiveOnChange: Boolean, ) { @@ -611,6 +635,7 @@ internal class PlatoonRepository( fingerprint = encoded.fingerprint, payload = encoded.payload, clearActiveOnChange = clearActiveOnChange, + zoneId = zoneId, ) } } @@ -629,6 +654,7 @@ internal class PlatoonRepository( val now = Instant.now() recordHistory( buildLiveWeeklyRevision(periodStart, zone, now), + zone, now, clearActiveOnChange = true, ) 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 94220cd..0c9b72e 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt @@ -4,8 +4,10 @@ import android.content.ContentValues import android.database.Cursor import android.database.sqlite.SQLiteDatabase import dev.gf2log.app.management.PlatoonDatabase.Companion.MAX_WEEKLY_REPORT_HISTORY +import java.nio.ByteBuffer import java.time.Instant import java.time.LocalDate +import java.time.ZoneId internal data class WeeklyReportHistoryReplacement( val periodStartEpochDay: Long, @@ -22,6 +24,7 @@ internal object WeeklyHistoryStore { * [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. + * History recorded before its reset zone was kept is adopted as [zoneId]'s (see [markZone]). */ fun record( db: SQLiteDatabase, @@ -30,9 +33,11 @@ internal object WeeklyHistoryStore { fingerprint: String, payload: ByteArray, clearActiveOnChange: Boolean, + zoneId: ZoneId? = null, ): Long { requireValidRevision(fingerprint, payload) return db.inTransaction { + zoneId?.let { markZone(db, it, replace = false) } val latest = db.rawQuery( "SELECT id, fingerprint FROM weekly_report_history " + "WHERE period_start = ? ORDER BY id DESC LIMIT 1", @@ -148,11 +153,16 @@ internal object WeeklyHistoryStore { * Replaces every derived weekly revision in one SQLite transaction, validating the complete * replacement before deleting existing history. */ - fun replace(db: SQLiteDatabase, replacements: List) { + fun replace( + db: SQLiteDatabase, + replacements: List, + zoneId: ZoneId? = null, + ) { require(replacements.map { it.periodStartEpochDay }.distinct().size == replacements.size) replacements.forEach { requireValidRevision(it.fingerprint, it.payload) } db.inTransaction { deleteAll(db) + zoneId?.let { markZone(db, it, replace = true) } replacements.forEach { replacement -> db.insertOrThrow( "weekly_report_history", @@ -168,6 +178,41 @@ internal object WeeklyHistoryStore { } } + /** + * Whether the stored history is known to be bucketed at [zoneId]'s 05:00 resets. The zone + * travels inside the database, so an archive restored under another reset zone, or one made + * before the zone was kept, reports false. + */ + fun isBucketedIn(db: SQLiteDatabase, zoneId: ZoneId): Boolean { + PlatoonDatabaseSchema.createPlatoonMaintenanceStateTable(db) + return db.rawQuery( + "SELECT value FROM platoon_maintenance_state WHERE key = ?", + arrayOf(HISTORY_ZONE_KEY), + ).use { cursor -> cursor.moveToFirst() && cursor.getLong(0) == zoneValue(zoneId) } + } + + /** + * Keeps the reset zone the history is bucketed in: a rebuild [replace]s it, while an ordinary + * write only adopts its zone for history recorded before the zone was kept. A zone change or a + * restore rebuilds the history, so an ordinary write never meets another recorded zone. + */ + private fun markZone(db: SQLiteDatabase, zoneId: ZoneId, replace: Boolean) { + PlatoonDatabaseSchema.createPlatoonMaintenanceStateTable(db) + db.insertWithOnConflict( + "platoon_maintenance_state", + null, + ContentValues().apply { + put("key", HISTORY_ZONE_KEY) + put("value", zoneValue(zoneId)) + }, + if (replace) SQLiteDatabase.CONFLICT_REPLACE else SQLiteDatabase.CONFLICT_IGNORE, + ) + } + + /** A stable 64-bit identity of the zone ID, as the maintenance table stores integers. */ + private fun zoneValue(zoneId: ZoneId): Long = + ByteBuffer.wrap(zoneId.id.toByteArray(Charsets.UTF_8).sha256()).long + private fun deleteAll(db: SQLiteDatabase) { db.delete("weekly_report_history_state", null, null) db.delete("weekly_report_history", null, null) @@ -191,6 +236,7 @@ internal object WeeklyHistoryStore { } private val FINGERPRINT = Regex("[0-9a-f]{64}") + private const val HISTORY_ZONE_KEY = "weekly_history_zone" /** Selects report_blob only within the payload limit, otherwise NULL. */ private val BOUNDED_PAYLOAD = diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index b5cfe9c..b9c6f8b 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -234,6 +234,10 @@ transitions. dates and report mode, in-period notes, and valid contextual identities. - Changing the configured game timezone clears incompatible derived history and rebuilds it from the unchanged UTC evidence using the new 05:00 boundary. + The database records the zone its history is bucketed in, so a committed + restore whose history was recorded under another effective zone, or before + the zone was recorded, rebuilds it the same way; one in the same zone keeps + its revisions and selection. - Captured evidence refreshes every listed week that reads it, not only the week of its game day. A roster captured exactly at Sunday 05:00 is the closing Saturday capture of the week it ends, so that week is re-recorded From 342974b31e1604e4250e92d3ba415393e5143ad3 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:17:33 +0900 Subject: [PATCH 49/98] Reject future manual membership dates and out-of-range UIDs A manual join or withdrawal may not lie after today in the game zone, or after now plus one hour when its time is known; the date picker stops at that day and the dialogs explain the error before the repository's require does. Only newly entered, editable sides are checked, so stored packet times that follow the server clock never block an edit. A manually added withdrawn member needs a game UID from 1 to 4294967295. Replacing an activity-dated boundary by hand also removes its ACTIVITY event and detaches the fact that dated it. Findings: q-input-6, q-time-boundaries-6, q-input-7, data-doc-contracts-3 --- .../dev/gf2log/app/DateTimePickerInput.kt | 31 ++++++----- .../dev/gf2log/app/MemberDetailActivity.kt | 15 ++++++ .../dev/gf2log/app/WithdrawnMemberDialog.kt | 12 ++++- .../app/management/MembershipEditing.kt | 49 ++++++++++++++++- .../app/management/MembershipHistory.kt | 21 ++++++++ app/src/main/res/values-ko/strings.xml | 3 +- app/src/main/res/values/strings.xml | 3 +- .../MembershipBoundaryPresentTest.kt | 52 +++++++++++++++++++ 8 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/management/MembershipBoundaryPresentTest.kt diff --git a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt index b05c339..6816b41 100644 --- a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt +++ b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt @@ -9,6 +9,7 @@ import android.view.View import android.widget.LinearLayout import android.widget.TextView import dev.gf2log.app.management.MembershipBoundaryValue +import dev.gf2log.app.management.latestMembershipBoundaryDate import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.TextRole @@ -97,19 +98,25 @@ internal class DateTimePickerInput( } private fun showDatePicker() { - val seed = selection.date ?: LocalDate.now(zone) - Dialogs.showTracked( - DatePickerDialog( - context, - { _, year, month, day -> - selection.selectDate(LocalDate.of(year, month + 1, day)) - renderValue() - }, - seed.year, - seed.monthValue - 1, - seed.dayOfMonth, - ), + val latest = latestMembershipBoundaryDate(zone) + val seed = minOf(selection.date ?: LocalDate.now(zone), latest) + val dialog = DatePickerDialog( + context, + { _, year, month, day -> + selection.selectDate(LocalDate.of(year, month + 1, day)) + renderValue() + }, + seed.year, + seed.monthValue - 1, + seed.dayOfMonth, ) + // A membership boundary cannot lie in the future. DatePicker reads its bound in the + // device timezone, so the last allowed day ends there rather than in the game zone. + dialog.datePicker.maxDate = latest.atTime(LocalTime.MAX) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + Dialogs.showTracked(dialog) } private fun showTimePicker() { diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 5b1c680..50b7a93 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -15,6 +15,7 @@ import dev.gf2log.app.management.MembershipPeriod import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.management.isJoinedBoundaryEditable +import dev.gf2log.app.management.isMembershipBoundaryWithinPresent import dev.gf2log.app.management.isLeftBoundaryEditable import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.ui.onTextChanged @@ -342,6 +343,16 @@ class MemberDetailActivity : BaseActivity() { error.showMessage(getString(R.string.invalid_date)) return@setOnClickListener } + // Packet-linked sides are read-only and follow the server clock, so only editable + // sides must not lie in the future. + val futureJoin = period.isJoinedBoundaryEditable && + !isMembershipBoundaryWithinPresent(joinedBoundary, zone) + val futureLeft = period.isLeftBoundaryEditable && leftBoundary != null && + !isMembershipBoundaryWithinPresent(leftBoundary, zone) + if (futureJoin || futureLeft) { + error.showMessage(getString(R.string.invalid_future_date)) + return@setOnClickListener + } button.isEnabled = false val noteText = noteInput.text.toString() work.run( @@ -407,6 +418,10 @@ class MemberDetailActivity : BaseActivity() { error.showMessage(getString(R.string.invalid_date)) return@setOnClickListener } + if (listOfNotNull(joinedBoundary, withdrewBoundary).any { !isMembershipBoundaryWithinPresent(it, zone) }) { + error.showMessage(getString(R.string.invalid_future_date)) + return@setOnClickListener + } button.isEnabled = false val noteText = noteInput.text.toString() work.run( diff --git a/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt b/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt index 4f23d9b..f9e9314 100644 --- a/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt +++ b/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt @@ -6,6 +6,7 @@ import android.app.AlertDialog import android.text.InputType import android.view.View import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.isMembershipBoundaryWithinPresent import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs @@ -26,7 +27,7 @@ internal object WithdrawnMemberDialog { onAdded: () -> Unit, ) = with(activity) { val zone = repository.gameZone() - val uid = LabeledField(this, getString(R.string.uid), textField(getString(R.string.uid_hint), inputType = InputType.TYPE_CLASS_NUMBER)) + val uid = LabeledField(this, getString(R.string.uid), textField(getString(R.string.uid_hint), inputType = InputType.TYPE_CLASS_NUMBER, maxLength = MAX_UID_DIGITS)) val nickname = LabeledField(this, getString(R.string.member_nickname), textField(getString(R.string.member_nickname), maxLength = ManualTextPolicy.MAX_MEMBER_NAME_CHARS)) val joined = DateTimePickerInput(this, getString(R.string.join_label), dateRequired = true, zone = zone) val withdrew = DateTimePickerInput(this, getString(R.string.withdraw_label), dateRequired = true, zone = zone) @@ -49,7 +50,9 @@ internal object WithdrawnMemberDialog { .setPositiveButton(R.string.add, null), ) { dialog -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> - val uidValue = uid.input.text.toString().trim().toLongOrNull()?.takeIf { it > 0 } + // Game UIDs are unsigned 32-bit values; a larger number could never match a packet. + val uidValue = uid.input.text.toString().trim().toLongOrNull() + ?.takeIf { it in 1L..UInt.MAX_VALUE.toLong() } val name = nickname.input.text.toString().trim() val joinedBoundary = joined.boundary val withdrewBoundary = withdrew.boundary @@ -59,6 +62,8 @@ internal object WithdrawnMemberDialog { name.isEmpty() -> R.string.invalid_nickname joinedBoundary == null || withdrewBoundary == null -> R.string.invalid_dates_required !isValidMembershipRange(joinedBoundary, withdrewBoundary) -> R.string.invalid_date_order + !isMembershipBoundaryWithinPresent(joinedBoundary, zone) || + !isMembershipBoundaryWithinPresent(withdrewBoundary, zone) -> R.string.invalid_future_date else -> null } if (problem != null || uidValue == null || joinedBoundary == null || withdrewBoundary == null) { @@ -82,4 +87,7 @@ internal object WithdrawnMemberDialog { } } } + + /** UInt.MAX_VALUE has ten digits. */ + private const val MAX_UID_DIGITS = 10 } 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 fd755c5..7210dc1 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -20,11 +20,15 @@ internal object MembershipEditing { joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue, note: String, + now: Instant = Instant.now(), ): Boolean { - require(uid > 0) + require(uid in VALID_UID_RANGE) { "A member UID must be a game UID" } val storedName = ManualTextPolicy.memberName(name) val storedNote = ManualTextPolicy.note(note) require(isValidMembershipRange(joined, withdrew)) + val zoneId = gameZone() + require(isMembershipBoundaryWithinPresent(joined, zoneId, now)) { FUTURE_BOUNDARY } + require(isMembershipBoundaryWithinPresent(withdrew, zoneId, now)) { FUTURE_BOUNDARY } return db.inTransaction { if (memberExists(db, uid)) return@inTransaction false MembershipRows.insertMember( @@ -50,9 +54,15 @@ internal object MembershipEditing { joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue?, note: String, + now: Instant = Instant.now(), ): Boolean { val storedNote = ManualTextPolicy.note(note) require(isValidMembershipRange(joined, withdrew)) + val zoneId = gameZone() + require(isMembershipBoundaryWithinPresent(joined, zoneId, now)) { FUTURE_BOUNDARY } + require(withdrew == null || isMembershipBoundaryWithinPresent(withdrew, zoneId, now)) { + FUTURE_BOUNDARY + } return db.inTransaction { val memberName = memberName(db, uid) ?: return@inTransaction false if (!canStoreMembershipPeriod(db, gameZone(), uid, null, joined, withdrew)) { @@ -145,6 +155,7 @@ internal object MembershipEditing { joined: MembershipBoundaryValue, left: MembershipBoundaryValue?, note: String, + now: Instant = Instant.now(), ): Boolean { val storedNote = ManualTextPolicy.note(note) return db.inTransaction { @@ -175,6 +186,21 @@ internal object MembershipEditing { requested = left, source = period.leftSource, ) + // Only a newly entered boundary is checked; stored packet times follow the server. + val zoneId = gameZone() + require(!joinedChanged || isMembershipBoundaryWithinPresent(joined, zoneId, now)) { + FUTURE_BOUNDARY + } + require(!leftChanged || left == null || isMembershipBoundaryWithinPresent(left, zoneId, now)) { + FUTURE_BOUNDARY + } + // A manual date replaces an activity dating, so its event and fact link go too. + if (joinedChanged && period.joinedSource == EvidenceSource.ACTIVITY) { + deleteActivityBoundaryEvents(db, membershipPeriodId, MembershipBoundary.JOIN) + } + if (leftChanged && period.leftSource == EvidenceSource.ACTIVITY) { + deleteActivityBoundaryEvents(db, membershipPeriodId, MembershipBoundary.WITHDRAW) + } val updated = db.update( "membership_periods", ContentValues().apply { @@ -341,6 +367,23 @@ internal object MembershipEditing { } } + private fun deleteActivityBoundaryEvents( + db: SQLiteDatabase, + membershipPeriodId: Long, + boundary: MembershipBoundary, + ) { + MembershipRows.deleteSupersededEvents( + db, + "membership_period_id = ? AND source = ? AND event_type IN (?, ?)", + arrayOf( + membershipPeriodId.toString(), + EvidenceSource.ACTIVITY.name, + boundary.eventTypes[0].name, + boundary.eventTypes[1].name, + ), + ) + } + private fun memberName(db: SQLiteDatabase, uid: Long): String? = db.rawQuery( "SELECT COALESCE(custom_name, current_name) FROM members WHERE uid = ?", @@ -384,6 +427,10 @@ internal object MembershipEditing { } } + /** Game UIDs are unsigned 32-bit values; any other number can never match a packet. */ + private val VALID_UID_RANGE = 1L..UInt.MAX_VALUE.toLong() + private const val FUTURE_BOUNDARY = "A membership boundary cannot be in the future" + private data class MembershipPeriodForUpdate( val uid: Long, val joined: MembershipBoundaryValue?, diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt b/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt index af7eeb1..7c730bb 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import java.time.Duration import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -16,6 +17,26 @@ data class MembershipBoundaryValue( val timeKnown: Boolean, ) +/** How far a manual boundary may run ahead of the device clock, as for a roster CSV import. */ +internal val MAX_MANUAL_BOUNDARY_CLOCK_SKEW: Duration = Duration.ofHours(1) + +/** The latest calendar date in [zone] that a manual membership boundary may carry at [now]. */ +fun latestMembershipBoundaryDate(zone: ZoneId, now: Instant = Instant.now()): LocalDate = + now.plus(MAX_MANUAL_BOUNDARY_CLOCK_SKEW).atZone(zone).toLocalDate() + +/** + * Whether a manual join or withdrawal has already happened at [now]: its calendar date is not + * after [latestMembershipBoundaryDate], and a known time is not after [now] beyond the clock + * skew. A future withdrawal would mark a current member withdrawn at once and overlap the next + * roster's period, and any future boundary would add phantom weeks. + */ +fun isMembershipBoundaryWithinPresent( + boundary: MembershipBoundaryValue, + zone: ZoneId, + now: Instant = Instant.now(), +): Boolean = !boundary.date.isAfter(latestMembershipBoundaryDate(zone, now)) && + (!boundary.timeKnown || !boundary.instant.isAfter(now.plus(MAX_MANUAL_BOUNDARY_CLOCK_SKEW))) + fun isValidMembershipRange( joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue?, diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6c7e86f..32338c9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -399,9 +399,10 @@ 전체 탈퇴 탈퇴는 가입 이후여야 합니다. + 가입일과 탈퇴일은 미래로 설정할 수 없습니다. 가입일과 탈퇴일을 모두 선택하세요. 닉네임을 입력하세요. - 양수 UID를 입력하세요. + 1부터 4294967295 사이의 숫자 UID를 입력하세요. 최근 접속 접속 %1$s 레벨 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77bdaea..03349f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -403,9 +403,10 @@ All Withdrawn Withdrawal must be after the join date. + Join and withdrawal dates can\'t be in the future. Select both the join and withdrawal dates. Enter a nickname. - Enter a positive numeric UID. + Enter a numeric UID from 1 to 4294967295. Last login Login %1$s Level diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryPresentTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryPresentTest.kt new file mode 100644 index 0000000..aef40d2 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryPresentTest.kt @@ -0,0 +1,52 @@ +package dev.gf2log.app.management + +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MembershipBoundaryPresentTest { + private val zone = ZoneId.of("Asia/Seoul") + private val now = Instant.parse("2026-11-06T03:00:00Z") // 12:00 on 2026-11-06 in Seoul + + @Test + fun `today and the past are accepted`() { + assertTrue(within(dateOnly(LocalDate.of(2026, 11, 6)))) + assertTrue(within(timed(now))) + assertTrue(within(dateOnly(LocalDate.of(2026, 1, 1)))) + } + + @Test + fun `a future withdrawal date is refused`() { + assertFalse(within(dateOnly(LocalDate.of(2026, 11, 7)))) + assertFalse(within(dateOnly(LocalDate.of(2062, 10, 5)))) + assertFalse(within(timed(now.plus(Duration.ofDays(30))))) + } + + @Test + fun `a known time may run ahead only by the clock skew`() { + assertTrue(within(timed(now.plus(MAX_MANUAL_BOUNDARY_CLOCK_SKEW)))) + assertFalse(within(timed(now.plus(MAX_MANUAL_BOUNDARY_CLOCK_SKEW).plusMillis(1)))) + } + + @Test + fun `the latest date follows the game zone calendar`() { + // 23:30 in Seoul: the skew already reaches the next calendar day there. + val lateEvening = Instant.parse("2026-11-06T14:30:00Z") + assertEquals(LocalDate.of(2026, 11, 7), latestMembershipBoundaryDate(zone, lateEvening)) + assertEquals(LocalDate.of(2026, 11, 6), latestMembershipBoundaryDate(zone, now)) + } + + private fun within(boundary: MembershipBoundaryValue) = + isMembershipBoundaryWithinPresent(boundary, zone, now) + + private fun dateOnly(date: LocalDate) = + MembershipBoundaryValue(date, date.atStartOfDay(zone).toInstant(), timeKnown = false) + + private fun timed(instant: Instant) = + MembershipBoundaryValue(instant.atZone(zone).toLocalDate(), instant, timeKnown = true) +} From 707caa277f1bb532e17b87e30d397457fc93fdfe Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:17:41 +0900 Subject: [PATCH 50/98] Repair stale membership state and keep exact patrol facts - The 21935 retention cap no longer evicts exact 21960 Daily Patrol facts, which are bounded by their own identity. - Membership periods are listed chronologically, by withdrawal when the join is unknown. - Every writable open re-derives is_active once whenever the active rule's version (platoon_maintenance_state active_state_rule) changes, so v13 databases from before 2.5.0 and restored backups are repaired. - The schema-5 upgrade removes the unlinked roster event that a 2.0.0 manual tenure edit superseded, so the boundary keeps one event. - Deleting a member removes unresolved activity under names that no other member ever held. - Home and Snapshot Comparison compare the latest roster with the newest earlier roster whose members differ. - Instrumented regression tests for these and the earlier membership fixes. Findings: data-doc-contracts-6, data-doc-contracts-4, data-migration-chain-1, data-migration-chain-m1, data-deletion-remnants-m2, data-capture-handoffs-1 --- .../MembershipIntegrityIntegrationTest.kt | 655 ++++++++++++++++++ .../PlatoonDatabaseIntegrationTest.kt | 5 +- .../main/java/dev/gf2log/app/MainActivity.kt | 9 +- .../gf2log/app/SnapshotComparisonActivity.kt | 6 +- .../app/management/ActivityIngestion.kt | 8 +- .../management/LegacyEventMigrationPolicy.kt | 52 +- .../app/management/MembershipEditing.kt | 32 + .../gf2log/app/management/MembershipRows.kt | 33 +- .../gf2log/app/management/PlatoonDatabase.kt | 1 + .../management/PlatoonDatabaseMigrations.kt | 71 ++ .../app/management/PlatoonReadQueries.kt | 5 +- .../app/management/SnapshotComparison.kt | 19 + .../LegacyEventMigrationPolicyTest.kt | 37 + .../SnapshotComparisonBaselineTest.kt | 63 ++ 14 files changed, 972 insertions(+), 24 deletions(-) create mode 100644 app/src/androidTest/java/dev/gf2log/app/management/MembershipIntegrityIntegrationTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/SnapshotComparisonBaselineTest.kt diff --git a/app/src/androidTest/java/dev/gf2log/app/management/MembershipIntegrityIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/MembershipIntegrityIntegrationTest.kt new file mode 100644 index 0000000..f6824cd --- /dev/null +++ b/app/src/androidTest/java/dev/gf2log/app/management/MembershipIntegrityIntegrationTest.kt @@ -0,0 +1,655 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** Membership-identity and boundary invariants that need a real SQLite database. */ +@RunWith(AndroidJUnit4::class) +class MembershipIntegrityIntegrationTest { + private lateinit var context: Context + private lateinit var database: PlatoonDatabase + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + context.deleteDatabase(TEST_DATABASE) + database = PlatoonDatabase(context, TEST_DATABASE, TEST_SCOPE) + } + + @After + fun tearDown() { + database.close() + context.deleteDatabase(TEST_DATABASE) + } + + @Test + fun aNoteOnlySaveKeepsFollowingInGameRenames() { + roster(T0, member(TARGET_UID, "Alice")) + assertTrue(database.updateMember(TARGET_UID, "Alice", "officer")) + assertNull(customName(TARGET_UID)) + + roster(T0.plusSeconds(HOUR), member(TARGET_UID, "Bob")) + val renamed = status(TARGET_UID) + assertEquals("Bob", renamed.name) + assertEquals("Bob", renamed.currentName) + assertEquals("officer", renamed.note) + + assertTrue(database.updateMember(TARGET_UID, " Carol ", "")) + assertEquals("Carol", customName(TARGET_UID)) + assertEquals("Carol", status(TARGET_UID).name) + assertTrue(database.updateMember(TARGET_UID, "Bob", "")) + assertNull(customName(TARGET_UID)) + assertTrue(database.updateMember(TARGET_UID, "Carol", "")) + assertTrue(database.updateMember(TARGET_UID, " ", "")) + assertNull(customName(TARGET_UID)) + assertEquals("Bob", status(TARGET_UID).name) + } + + @Test + fun rosterTimeCorrelationIgnoresActivityBeyondTheLeadBound() { + roster(T0, member(OTHER_UID, "Other"), member(TARGET_UID, "Leaver")) + database.ingestPlatoonActivity(listOf(activity("Leaver", T0.plusSeconds(30 * 60))), T0.plusSeconds(HOUR)) + val revealingAt = T0.plusSeconds(72 * HOUR) + roster(revealingAt, member(OTHER_UID, "Other")) + + val period = status(TARGET_UID).membershipPeriods.single() + assertEquals(revealingAt, period.leftAt) + assertEquals(EvidencePrecision.INFERRED, period.leftPrecision) + assertEquals(EvidenceSource.SNAPSHOT, period.leftSource) + assertEquals(0L, count("platoon_activity", "member_event_id IS NOT NULL")) + } + + @Test + fun rosterTimeCorrelationDatesAWithdrawalFromActivityWithinTheLeadBound() { + roster(T0, member(OTHER_UID, "Other"), member(TARGET_UID, "Leaver")) + val revealingAt = T0.plusSeconds(6 * HOUR) + val actedAt = revealingAt.minusSeconds(HOUR) + database.ingestPlatoonActivity(listOf(activity("Leaver", actedAt)), actedAt.plusSeconds(60)) + roster(revealingAt, member(OTHER_UID, "Other")) + + val period = status(TARGET_UID).membershipPeriods.single() + assertEquals(actedAt, period.leftAt) + assertEquals(EvidenceSource.ACTIVITY, period.leftSource) + assertTrue(period.isLeftBoundaryEditable) + assertEquals(1L, count("member_events", "source = 'ACTIVITY' AND event_type = 'LEFT'")) + assertEquals(0L, count("member_events", "source = 'GAME_UPDATES'")) + } + + @Test + fun anActivityResolvedToAnotherUidNeverDatesANewJoin() { + roster(T0, member(OTHER_UID, "Other"), member(TARGET_UID, "Bob")) + val actedAt = T0.plusSeconds(HOUR) + database.ingestPlatoonActivity(listOf(activity("Bob", actedAt)), actedAt.plusSeconds(60)) + assertEquals(1L, count("platoon_activity", "resolved_uid = $TARGET_UID")) + database.ingestPlatoonUpdates( + listOf(update(PlatoonUpdateSemantics.KIND_WITHDRAW, T0.plusSeconds(2 * HOUR), TARGET_UID, "Rob")), + T0.plusSeconds(2 * HOUR), + ) + roster(T0.plusSeconds(3 * HOUR), member(OTHER_UID, "Other"), member(THIRD_UID, "Bob")) + + val joined = status(THIRD_UID).membershipPeriods.single() + assertEquals(EvidencePrecision.INFERRED, joined.joinedPrecision) + assertEquals(T0.plusSeconds(3 * HOUR), joined.joinedAt) + assertEquals(1L, count("platoon_activity", "resolved_uid = $TARGET_UID AND member_event_id IS NULL")) + } + + @Test + fun aLaterUpdatesJoinReplacesAnActivityDatedJoin() { + val previousAt = Instant.parse("2026-08-03T09:00:00Z") + val actedAt = Instant.parse("2026-08-03T10:20:00Z") + val revealingAt = Instant.parse("2026-08-03T12:00:00Z") + roster(previousAt, member(OTHER_UID, "Other")) + database.ingestPlatoonActivity(listOf(activity("Target", actedAt)), actedAt.plusSeconds(600)) + roster(revealingAt, member(OTHER_UID, "Other"), member(TARGET_UID, "Target")) + val dated = status(TARGET_UID).membershipPeriods.single() + assertEquals(actedAt, dated.joinedAt) + assertEquals(EvidenceSource.ACTIVITY, dated.joinedSource) + + val joinedAt = Instant.parse("2026-08-03T10:00:00Z") + database.ingestPlatoonUpdates( + listOf(update(PlatoonUpdateSemantics.KIND_JOIN, joinedAt, TARGET_UID, "Target")), + revealingAt.plusSeconds(60), + ) + + val period = status(TARGET_UID).membershipPeriods.single() + assertEquals(joinedAt, period.joinedAt) + assertEquals(EvidenceSource.GAME_UPDATES, period.joinedSource) + assertNull(period.leftAt) + assertEquals(1L, count("member_events", "uid = $TARGET_UID AND event_type IN ('JOINED', 'REJOINED')")) + assertEquals(0L, count("platoon_activity", "member_event_id IS NOT NULL")) + assertEquals(0L, count("platoon_activity", "resolution = 'MEMBERSHIP_CORRELATION'")) + assertTrue(status(TARGET_UID).isActive) + assertForeignKeysValid() + } + + @Test + fun aLaterUpdatesWithdrawalReplacesAnActivityDatedWithdrawal() { + val previousAt = Instant.parse("2026-08-03T09:00:00Z") + val actedAt = Instant.parse("2026-08-03T10:20:00Z") + val revealingAt = Instant.parse("2026-08-03T12:00:00Z") + roster(previousAt, member(OTHER_UID, "Other"), member(TARGET_UID, "Target")) + database.ingestPlatoonActivity(listOf(activity("Target", actedAt)), actedAt.plusSeconds(600)) + roster(revealingAt, member(OTHER_UID, "Other")) + assertEquals(EvidenceSource.ACTIVITY, status(TARGET_UID).membershipPeriods.single().leftSource) + + val withdrewAt = Instant.parse("2026-08-03T11:00:00Z") + database.ingestPlatoonUpdates( + listOf(update(PlatoonUpdateSemantics.KIND_WITHDRAW, withdrewAt, TARGET_UID, "Target")), + revealingAt.plusSeconds(60), + ) + + val period = status(TARGET_UID).membershipPeriods.single() + assertEquals(withdrewAt, period.leftAt) + assertEquals(EvidenceSource.GAME_UPDATES, period.leftSource) + assertEquals(1L, count("member_events", "uid = $TARGET_UID AND event_type IN ('LEFT', 'REMOVED')")) + assertFalse(status(TARGET_UID).isActive) + assertForeignKeysValid() + } + + @Test + fun aLateUpdatesJoinFoldsIntoTheRosterInferredJoinOfTheSameGap() { + val monday = Instant.parse("2026-08-03T00:00:00Z") + val sunday = Instant.parse("2026-08-09T15:00:00Z") + roster(monday, member(OTHER_UID, "Other")) + roster(sunday, member(OTHER_UID, "Other"), member(TARGET_UID, "Target")) + + val joinedAt = Instant.parse("2026-08-04T10:00:00Z") + database.ingestPlatoonUpdates( + listOf(update(PlatoonUpdateSemantics.KIND_JOIN, joinedAt, TARGET_UID, "Target")), + sunday.plusSeconds(HOUR), + ) + + val period = status(TARGET_UID).membershipPeriods.single() + assertEquals(joinedAt, period.joinedAt) + assertEquals(EvidencePrecision.EXACT, period.joinedPrecision) + assertEquals(EvidenceSource.GAME_UPDATES, period.joinedSource) + assertNull(period.leftAt) + assertEquals(1L, count("member_events", "uid = $TARGET_UID AND event_type IN ('JOINED', 'REJOINED')")) + assertForeignKeysValid() + } + + @Test + fun aLateUpdatesWithdrawalFoldsIntoTheRosterInferredWithdrawalOfTheSameGap() { + val monday = Instant.parse("2026-08-03T00:00:00Z") + val sunday = Instant.parse("2026-08-09T15:00:00Z") + roster(monday, member(OTHER_UID, "Other"), member(TARGET_UID, "Target")) + roster(sunday, member(OTHER_UID, "Other")) + + val withdrewAt = Instant.parse("2026-08-04T10:00:00Z") + database.ingestPlatoonUpdates( + listOf(update(PlatoonUpdateSemantics.KIND_WITHDRAW, withdrewAt, TARGET_UID, "Target")), + sunday.plusSeconds(HOUR), + ) + + val period = status(TARGET_UID).membershipPeriods.single() + assertEquals(withdrewAt, period.leftAt) + assertEquals(EvidenceSource.GAME_UPDATES, period.leftSource) + assertEquals(1L, count("member_events", "uid = $TARGET_UID AND event_type IN ('LEFT', 'REMOVED')")) + assertForeignKeysValid() + } + + @Test + fun aRosterOmissionClosesEveryOpenPeriod() { + roster(T0, member(OTHER_UID, "Other"), member(TARGET_UID, "Target")) + insertOpenPeriod(TARGET_UID, T0.minusSeconds(10 * 24 * HOUR), EvidencePrecision.MANUAL, EvidenceSource.MANUAL) + insertOpenPeriod(TARGET_UID, T0.minusSeconds(24 * HOUR), EvidencePrecision.EXACT, EvidenceSource.GAME_UPDATES) + assertEquals(3L, count("membership_periods", "uid = $TARGET_UID AND left_at IS NULL")) + + val omittedAt = T0.plusSeconds(HOUR) + roster(omittedAt, member(OTHER_UID, "Other")) + + assertEquals(0L, count("membership_periods", "uid = $TARGET_UID AND left_at IS NULL")) + // The roster-only duplicate was folded; both real periods close at the omission. + assertEquals(2L, count("membership_periods", "uid = $TARGET_UID AND left_at = ${omittedAt.toEpochMilli()}")) + assertEquals(2L, count("member_events", "uid = $TARGET_UID AND event_type = 'LEFT'")) + assertFalse(status(TARGET_UID).isActive) + assertForeignKeysValid() + } + + @Test + fun aWithdrawalIsDatedByTheLatestAdmittedActivity() { + val previousAt = Instant.parse("2026-08-03T06:00:00Z") + val revealingAt = Instant.parse("2026-08-03T21:00:00Z") + roster(previousAt, member(OTHER_UID, "Other"), member(TARGET_UID, "Target")) + roster(revealingAt, member(OTHER_UID, "Other")) + + val lastAction = Instant.parse("2026-08-03T18:00:00Z") + database.ingestPlatoonActivity( + listOf(activity("Target", Instant.parse("2026-08-03T10:00:00Z")), activity("Target", lastAction)), + revealingAt.plusSeconds(HOUR), + ) + + val period = status(TARGET_UID).membershipPeriods.single() + assertEquals(lastAction, period.leftAt) + assertEquals(EvidenceSource.ACTIVITY, period.leftSource) + } + + @Test + fun activityRetentionNeverEvictsExactDailyPatrolFacts() { + val patrolAt = Instant.parse("2026-07-01T00:00:00Z") + database.ingestPlatoonUpdates( + listOf(update(PlatoonUpdateSemantics.KIND_DAILY_PATROL, patrolAt, TARGET_UID, "Target")), + patrolAt, + ) + val writable = database.writableDatabase + writable.beginTransaction() + try { + repeat(PlatoonDatabase.MAX_STORED_ACTIVITY_OBSERVATIONS + 5) { index -> + writable.insertOrThrow( + "platoon_activity", + null, + ContentValues().apply { + put("occurred_at", patrolAt.toEpochMilli() + index + 1) + put("action_id", 701_001L) + put("kind", 1L) + put("member_name", "Unresolved $index") + put("captured_at", patrolAt.toEpochMilli() + index + 1) + put("resolution", ActivityResolution.UNRESOLVED.name) + }, + ) + } + writable.setTransactionSuccessful() + } finally { + writable.endTransaction() + } + reopen() + + assertEquals( + PlatoonDatabase.MAX_STORED_ACTIVITY_OBSERVATIONS.toLong(), + count("platoon_activity", "resolution = 'UNRESOLVED'"), + ) + assertEquals( + listOf(DailyPatrolFact(TARGET_UID, patrolAt)), + database.listDailyPatrolFacts(patrolAt, patrolAt.plusSeconds(HOUR)), + ) + } + + @Test + fun membershipPeriodsAreListedChronologically() { + roster(T0, member(TARGET_UID, "Target"), member(OTHER_UID, "Other")) + roster(T0.plusSeconds(10 * 24 * HOUR), member(OTHER_UID, "Other")) + roster(T0.plusSeconds(20 * 24 * HOUR), member(TARGET_UID, "Target"), member(OTHER_UID, "Other")) + + val periods = status(TARGET_UID).membershipPeriods + assertEquals(2, periods.size) + assertNull(periods[0].joinedAt) + assertEquals(T0.plusSeconds(10 * 24 * HOUR), periods[0].leftAt) + assertEquals(T0.plusSeconds(20 * 24 * HOUR), periods[1].joinedAt) + assertNull(periods[1].leftAt) + } + + @Test + fun deletingAMemberRemovesUnresolvedActivityOnlyItCouldOwn() { + roster(T0, member(TARGET_UID, "Ghost"), member(OTHER_UID, "Shared"), member(THIRD_UID, "Shared")) + insertUnresolvedActivity("Ghost", T0.plusSeconds(60)) + insertUnresolvedActivity("Shared", T0.plusSeconds(120)) + + assertTrue(database.deleteMember(TARGET_UID)) + assertTrue(database.deleteMember(THIRD_UID)) + + assertEquals(0L, count("platoon_activity", "member_name = 'Ghost'")) + // OTHER_UID still holds the name, so the fact's owner stays unknown and it is kept. + assertEquals(1L, count("platoon_activity", "member_name = 'Shared'")) + assertForeignKeysValid() + } + + @Test + fun manualMembershipRejectsFutureBoundariesAndOutOfRangeUids() { + val joined = boundary(Instant.parse("2020-01-01T00:00:00Z")) + val withdrew = boundary(Instant.parse("2020-02-01T00:00:00Z")) + assertThrows(IllegalArgumentException::class.java) { + database.addWithdrawnMember(4_294_967_296L, "Phantom", joined, withdrew, "") + } + assertTrue(database.addWithdrawnMember(4_294_967_295L, "Largest", joined, withdrew, "")) + + val future = MembershipBoundaryValue( + LocalDate.of(2062, 10, 5), + LocalDate.of(2062, 10, 5).atStartOfDay(ZoneOffset.UTC).toInstant(), + timeKnown = false, + ) + assertThrows(IllegalArgumentException::class.java) { + database.addWithdrawnMember(TARGET_UID, "Future", joined, future, "") + } + assertEquals(0L, count("members", "uid = $TARGET_UID")) + assertFalse(database.listWeeklyEvidenceDays(ZoneOffset.UTC).any { it.year == 2062 }) + + val inAMonth = boundary(Instant.now().plus(Duration.ofDays(30))) + val largest = status(4_294_967_295L).membershipPeriods.single() + assertThrows(IllegalArgumentException::class.java) { + database.updateMembershipPeriod(largest.id, joined, inAMonth, "") + } + } + + @Test + fun openingRederivesActiveStateWrittenByAnOlderRule() { + val writable = database.writableDatabase + writable.execSQL( + "INSERT INTO members(uid, current_name, current_level, is_active, first_seen_at, last_seen_at) " + + "VALUES(?, ?, 60, 1, ?, ?)", + arrayOf(TARGET_UID, "Target", T0.toEpochMilli(), T0.toEpochMilli()), + ) + insertOpenPeriod(TARGET_UID, T0, EvidencePrecision.EXACT, EvidenceSource.GAME_UPDATES) + SnapshotIngestion.insertSnapshotRows( + writable, + PlatoonSnapshot(id = 0, capturedAt = T0.plusSeconds(HOUR), members = listOf(member(OTHER_UID, "Other"))), + ) + writable.execSQL( + "UPDATE members SET is_active = 1 WHERE uid = ?", + arrayOf(TARGET_UID), + ) + writable.delete("platoon_maintenance_state", "key = ?", arrayOf(MembershipRows.ACTIVE_STATE_RULE_KEY)) + reopen() + + assertFalse(status(TARGET_UID).isActive) + assertEquals( + MembershipRows.ACTIVE_STATE_RULE_VERSION, + queryLong( + "SELECT value FROM platoon_maintenance_state WHERE key = '${MembershipRows.ACTIVE_STATE_RULE_KEY}'", + ), + ) + } + + @Test + fun schemaFourteenUpgradeMarksActivityDatedBoundariesAndFoldsTheirDuplicates() { + val writable = database.writableDatabase + writable.execSQL( + "INSERT INTO members(uid, current_name, current_level, is_active, first_seen_at, last_seen_at) " + + "VALUES(?, ?, 60, 1, ?, ?)", + arrayOf(TARGET_UID, "Target", T0.toEpochMilli(), T0.toEpochMilli()), + ) + val actedAt = T0.plusSeconds(20 * 60) + val activityDated = insertOpenPeriod(TARGET_UID, actedAt, EvidencePrecision.EXACT, EvidenceSource.GAME_UPDATES) + val activityEvent = insertJoinEvent(activityDated, actedAt) + writable.insertOrThrow( + "platoon_activity", + null, + ContentValues().apply { + put("occurred_at", actedAt.toEpochMilli()) + put("action_id", 701_001L) + put("kind", 1L) + put("member_name", "Target") + put("captured_at", actedAt.toEpochMilli()) + put("resolved_uid", TARGET_UID) + put("resolution", ActivityResolution.MEMBERSHIP_CORRELATION.name) + put("member_event_id", activityEvent) + }, + ) + val exact = insertOpenPeriod(TARGET_UID, T0, EvidencePrecision.EXACT, EvidenceSource.GAME_UPDATES) + insertJoinEvent(exact, T0) + database.close() + context.openOrCreateDatabase(TEST_DATABASE, Context.MODE_PRIVATE, null).use { legacy -> + legacy.version = 13 + } + database = PlatoonDatabase(context, TEST_DATABASE, TEST_SCOPE) + + assertEquals(PlatoonSchema.CURRENT_VERSION, database.writableDatabase.version) + val period = status(TARGET_UID).membershipPeriods.single() + assertEquals(exact, period.id) + assertEquals(EvidenceSource.GAME_UPDATES, period.joinedSource) + assertEquals(1L, count("member_events", "uid = $TARGET_UID")) + assertEquals( + 1L, + count("platoon_activity", "resolution = 'UNIQUE_ROSTER_NAME' AND member_event_id IS NULL"), + ) + assertForeignKeysValid() + } + + @Test + fun schemaFiveUpgradeKeepsOneEventForAManuallyEditedTenure() { + val databaseName = "platoon-v5-manual-tenure-test.db" + context.deleteDatabase(databaseName) + val joinedAt = Instant.parse("2026-07-20T09:00:00Z").toEpochMilli() + val observedAt = Instant.parse("2026-07-23T00:00:00Z").toEpochMilli() + try { + context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null).use { legacy -> + SCHEMA_FIVE.forEach { statement -> legacy.execSQL(statement.trimIndent()) } + legacy.execSQL( + "INSERT INTO members(uid, current_name, current_level, is_active, first_seen_at, last_seen_at) " + + "VALUES(?, 'Target', 60, 1, ?, ?)", + arrayOf(TARGET_UID, observedAt, observedAt), + ) + legacy.execSQL( + "INSERT INTO tenures(uid, joined_at, joined_precision, joined_source) " + + "VALUES(?, ?, 'MANUAL', 'MANUAL')", + arrayOf(TARGET_UID, joinedAt), + ) + legacy.execSQL( + "INSERT INTO member_events(uid, event_type, occurred_at, observed_at, precision, source, note) " + + "VALUES(?, 'JOINED', NULL, ?, 'INFERRED', 'SNAPSHOT', 'Target')", + arrayOf(TARGET_UID, observedAt), + ) + legacy.version = 5 + } + + PlatoonDatabase(context, databaseName, TEST_SCOPE).use { upgraded -> + upgraded.readableDatabase.rawQuery( + "SELECT source, membership_period_id IS NOT NULL FROM member_events " + + "WHERE uid = ? AND event_type IN ('JOINED', 'REJOINED')", + arrayOf(TARGET_UID.toString()), + ).use { cursor -> + assertEquals(1, cursor.count) + assertTrue(cursor.moveToFirst()) + assertEquals(EvidenceSource.MANUAL.name, cursor.getString(0)) + assertEquals(1, cursor.getInt(1)) + } + } + } finally { + context.deleteDatabase(databaseName) + } + } + + private fun reopen() { + database.close() + database = PlatoonDatabase(context, TEST_DATABASE, TEST_SCOPE) + database.writableDatabase + } + + private fun roster(capturedAt: Instant, vararg members: SnapshotMember) { + database.ingestSnapshot( + PlatoonSnapshot(id = 0, capturedAt = capturedAt, members = members.toList()), + EvidenceSource.SNAPSHOT, + ) + } + + private fun status(uid: Long): MemberStatus = database.listMemberStatuses().single { it.uid == uid } + + private fun customName(uid: Long): String? = + database.readableDatabase.rawQuery( + "SELECT custom_name FROM members WHERE uid = ?", + arrayOf(uid.toString()), + ).use { cursor -> + check(cursor.moveToFirst()) + if (cursor.isNull(0)) null else cursor.getString(0) + } + + private fun insertOpenPeriod( + uid: Long, + joinedAt: Instant, + precision: EvidencePrecision, + source: EvidenceSource, + ): Long = MembershipRows.insertMembershipPeriod(database.writableDatabase, uid, joinedAt, precision, source) + + private fun insertJoinEvent(membershipPeriodId: Long, occurredAt: Instant): Long = + MembershipRows.insertEvent( + db = database.writableDatabase, + uid = TARGET_UID, + type = MemberEventType.JOINED, + occurredAt = occurredAt, + observedAt = occurredAt, + precision = EvidencePrecision.EXACT, + source = EvidenceSource.GAME_UPDATES, + note = "Target", + membershipPeriodId = membershipPeriodId, + ) + + private fun insertUnresolvedActivity(name: String, occurredAt: Instant) { + database.writableDatabase.insertOrThrow( + "platoon_activity", + null, + ContentValues().apply { + put("occurred_at", occurredAt.toEpochMilli()) + put("action_id", 701_001L) + put("kind", 1L) + put("member_name", name) + put("captured_at", occurredAt.toEpochMilli()) + put("resolution", ActivityResolution.UNRESOLVED.name) + }, + ) + } + + private fun boundary(instant: Instant) = + MembershipBoundaryValue(instant.atZone(ZoneOffset.UTC).toLocalDate(), instant, timeKnown = true) + + private fun activity(name: String, occurredAt: Instant) = PlatoonActivityObservation( + occurredAt = occurredAt, + actionId = 701_001L, + kind = 1, + memberName = name, + ) + + private fun update(kind: Long, at: Instant, uid: Long, name: String) = + PlatoonUpdateObservation( + kind = kind, + occurredAt = at, + members = listOf(PlatoonUpdateMemberObservation(role = 0, uid = uid, name = name)), + ) + + private fun member(uid: Long, name: String) = SnapshotMember( + uid = uid, + name = name, + level = 60, + weeklyMerit = 120, + totalMerit = 4_560, + highScore = 789, + totalScore = 1_234, + lastLogin = 1_700_000_000, + ) + + private fun count(table: String, selection: String): Long = + queryLong("SELECT COUNT(*) FROM $table WHERE $selection") + + private fun queryLong(sql: String): Long = + database.readableDatabase.rawQuery(sql, null).use { cursor -> + check(cursor.moveToFirst()) + cursor.getLong(0) + } + + private fun assertForeignKeysValid() { + database.readableDatabase.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> + assertFalse(cursor.moveToFirst()) + } + } + + private companion object { + const val TEST_DATABASE = "platoon-membership-integrity-test.db" + val TEST_SCOPE = PlatoonStorageScope("1".repeat(32)) + const val TARGET_UID = 1001L + const val OTHER_UID = 2002L + const val THIRD_UID = 3003L + const val HOUR = 60L * 60L + val T0: Instant = Instant.parse("2026-08-03T00:00:00Z") + + /** The 2.0.0 (schema v5) tables, before membership periods and calendar dates. */ + val SCHEMA_FIVE = listOf( + """ + CREATE TABLE snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at INTEGER NOT NULL, + source_file TEXT UNIQUE, + game_version TEXT + ) + """, + """ + CREATE TABLE snapshot_members ( + snapshot_id INTEGER NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE, + uid INTEGER NOT NULL, + name TEXT NOT NULL, + level INTEGER NOT NULL, + weekly_merit INTEGER NOT NULL, + total_merit INTEGER NOT NULL, + high_score INTEGER NOT NULL, + total_score INTEGER NOT NULL, + last_login INTEGER NOT NULL, + PRIMARY KEY(snapshot_id, uid) + ) + """, + """ + CREATE TABLE members ( + uid INTEGER PRIMARY KEY, + current_name TEXT NOT NULL, + custom_name TEXT, + current_level INTEGER NOT NULL, + is_active INTEGER NOT NULL, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + note TEXT NOT NULL DEFAULT '' + ) + """, + """ + CREATE TABLE tenures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER NOT NULL REFERENCES members(uid), + joined_at INTEGER, + left_at INTEGER, + joined_precision TEXT NOT NULL, + left_precision TEXT, + joined_source TEXT NOT NULL, + left_source TEXT, + note TEXT NOT NULL DEFAULT '' + ) + """, + "CREATE INDEX tenures_uid ON tenures(uid, id DESC)", + """ + CREATE TABLE member_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER NOT NULL REFERENCES members(uid), + event_type TEXT NOT NULL, + occurred_at INTEGER, + observed_at INTEGER NOT NULL, + precision TEXT NOT NULL, + source TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '' + ) + """, + "CREATE INDEX member_events_time ON member_events(observed_at DESC)", + """ + CREATE TABLE weekly_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_start INTEGER NOT NULL, + game_day INTEGER NOT NULL, + text TEXT NOT NULL, + event_id INTEGER REFERENCES member_events(id) ON DELETE SET NULL, + is_automatic INTEGER NOT NULL DEFAULT 0 + ) + """, + """ + CREATE TABLE weekly_overrides ( + uid INTEGER NOT NULL, + period_start INTEGER NOT NULL, + game_day INTEGER NOT NULL, + merit_delta INTEGER, + score_delta INTEGER, + attempts INTEGER, + attended INTEGER, + daily_patrol INTEGER, + PRIMARY KEY(uid, game_day) + ) + """, + ) + } +} 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 d64e26e..274f642 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -887,7 +887,8 @@ class PlatoonDatabaseIntegrationTest { val writable = database.writableDatabase writable.beginTransaction() try { - repeat(PlatoonDatabase.MAX_STORED_ACTIVITY_OBSERVATIONS) { index -> + // One 21935 fact over the cap; the exact Daily Patrol fact never counts toward it. + repeat(PlatoonDatabase.MAX_STORED_ACTIVITY_OBSERVATIONS + 1) { index -> writable.insertOrThrow( "platoon_activity", null, @@ -920,7 +921,7 @@ class PlatoonDatabaseIntegrationTest { assertEquals(1, result.patrolFacts) assertEquals( - PlatoonDatabase.MAX_STORED_ACTIVITY_OBSERVATIONS.toLong(), + PlatoonDatabase.MAX_STORED_ACTIVITY_OBSERVATIONS.toLong() + 1, count("platoon_activity", "id > ?", 0), ) assertEquals(0L, count("platoon_activity", "action_id = ?", 1)) diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 64e4a72..5c96507 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -277,11 +277,10 @@ class MainActivity : BaseActivity() { key = SUMMARY, task = { 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, repository.gameZone()) + val snapshots = repository.listSnapshots(SnapshotComparison.COMPARISON_LOOKBACK) + SnapshotComparison.latestComparablePair(snapshots)?.let { (latest, baseline) -> + latest to baseline?.let { older -> + SnapshotComparison.compare(older, latest, repository.gameZone()) } } }, diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index faca57e..f30463c 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -49,9 +49,9 @@ class SnapshotComparisonActivity : BaseActivity() { work.run( task = { val repository = PlatoonRepository(this, scope) - val snapshots = repository.listSnapshots(2) - snapshots.takeIf { it.size == 2 }?.let { - SnapshotComparison.compare(it[1], it[0], repository.gameZone()) + val snapshots = repository.listSnapshots(SnapshotComparison.COMPARISON_LOOKBACK) + SnapshotComparison.latestComparablePair(snapshots)?.let { (latest, baseline) -> + baseline?.let { SnapshotComparison.compare(it, latest, repository.gameZone()) } } }, ) { result -> 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 823b329..90381a5 100644 --- a/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt +++ b/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt @@ -202,8 +202,11 @@ internal object ActivityIngestion { } /** - * Caps network-derived activity evidence to the newest bounded history. Runs on open and after - * each ingest so an older oversized database self-heals. + * Caps network-derived 21935 activity evidence to the newest bounded history. Runs on open + * and after each ingest so an older oversized database self-heals. Exact 21960 Daily Patrol + * facts share the table but not the cap: they are the only Daily Patrol evidence and are + * bounded by their own identity (one per UID, kind, and instant), so newer activity never + * evicts them. */ fun trimPlatoonActivity(db: SQLiteDatabase) { db.execSQL( @@ -212,6 +215,7 @@ internal object ActivityIngestion { WHERE id IN ( SELECT id FROM platoon_activity + WHERE resolution != '${ActivityResolution.EXACT_UPDATE.name}' ORDER BY captured_at DESC, id DESC LIMIT -1 OFFSET $MAX_STORED_ACTIVITY_OBSERVATIONS ) diff --git a/app/src/main/java/dev/gf2log/app/management/LegacyEventMigrationPolicy.kt b/app/src/main/java/dev/gf2log/app/management/LegacyEventMigrationPolicy.kt index 39773e9..425c21f 100644 --- a/app/src/main/java/dev/gf2log/app/management/LegacyEventMigrationPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/LegacyEventMigrationPolicy.kt @@ -14,15 +14,7 @@ internal object LegacyEventMigrationPolicy { boundaryAt: Long, candidates: List, ): Long? { - val compatibleTypes = when (expectedType) { - MemberEventType.JOINED, - MemberEventType.REJOINED, - -> setOf(MemberEventType.JOINED, MemberEventType.REJOINED) - MemberEventType.LEFT, - MemberEventType.REMOVED, - -> setOf(MemberEventType.LEFT, MemberEventType.REMOVED) - MemberEventType.RENAMED -> setOf(MemberEventType.RENAMED) - } + val compatibleTypes = compatibleTypes(expectedType) return candidates .asSequence() .filter { candidate -> @@ -42,6 +34,48 @@ internal object LegacyEventMigrationPolicy { ?.id } + /** + * The unlinked roster event that a 2.0.0 manual tenure edit superseded. That version rewrote + * the boundary's source to MANUAL without touching its events, so the original roster event + * survives at its own time. Only a compatible roster event between the member's previous + * period's withdrawal ([lowerBound]) and next period's join ([upperBound]), both exclusive + * and unbounded when null, can be that event; the one nearest [boundaryAt] is chosen. + */ + fun selectSupersededSnapshotCandidate( + expectedType: MemberEventType, + boundaryAt: Long, + lowerBound: Long?, + upperBound: Long?, + candidates: List, + ): Long? { + val compatibleTypes = compatibleTypes(expectedType) + return candidates + .asSequence() + .filter { it.source in SNAPSHOT_SOURCES && it.type in compatibleTypes } + .filter { candidate -> + val at = candidate.occurredAt ?: candidate.observedAt + (lowerBound == null || at > lowerBound) && (upperBound == null || at < upperBound) + } + .sortedWith( + compareBy { + kotlin.math.abs((it.occurredAt ?: it.observedAt) - boundaryAt) + }.thenBy(LegacyEventCandidate::id), + ) + .firstOrNull() + ?.id + } + + private fun compatibleTypes(expectedType: MemberEventType): Set = + when (expectedType) { + MemberEventType.JOINED, + MemberEventType.REJOINED, + -> setOf(MemberEventType.JOINED, MemberEventType.REJOINED) + MemberEventType.LEFT, + MemberEventType.REMOVED, + -> setOf(MemberEventType.LEFT, MemberEventType.REMOVED) + MemberEventType.RENAMED -> setOf(MemberEventType.RENAMED) + } + private val SNAPSHOT_SOURCES = setOf( EvidenceSource.SNAPSHOT, EvidenceSource.LEGACY_IMPORT, 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 7210dc1..bbc9c72 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -102,6 +102,7 @@ internal object MembershipEditing { if (!memberExists(db, uid)) return@inTransaction false val uidArgument = arrayOf(uid.toString()) db.delete("weekly_overrides", "uid = ?", uidArgument) + deleteUnresolvedActivityOwnedBy(db, uid) db.delete( "platoon_activity", "resolved_uid = ? OR member_event_id IN " + @@ -117,6 +118,37 @@ internal object MembershipEditing { } } + /** + * Unresolved activity facts carry only a name. A name that no other member ever held can + * belong to nobody else, so those facts leave with the member instead of resolving later to + * a member re-added under that name. A shared name keeps its facts, whose owner is unknown. + */ + private fun deleteUnresolvedActivityOwnedBy(db: SQLiteDatabase, uid: Long) { + db.execSQL( + """ + DELETE FROM platoon_activity + WHERE resolved_uid IS NULL + AND member_name IN ( + SELECT owned.name + FROM ( + SELECT current_name AS name FROM members WHERE uid = ? + UNION + SELECT name FROM snapshot_members WHERE uid = ? + ) owned + WHERE NOT EXISTS ( + SELECT 1 FROM members other + WHERE other.uid != ? AND other.current_name = owned.name COLLATE BINARY + ) + AND NOT EXISTS ( + SELECT 1 FROM snapshot_members other + WHERE other.uid != ? AND other.name = owned.name COLLATE BINARY + ) + ) + """.trimIndent(), + arrayOf(uid, uid, uid, uid), + ) + } + /** * Deletes one membership period and its derived boundary events atomically. The member's * final period is protected; returns false when the period is missing or is the only one. 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 0df4002..4226a5e 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt @@ -385,8 +385,8 @@ internal object MembershipRows { * The one rule for `members.is_active`: a member is active while an effective open period * remains. An open period started by an exact Updates join is superseded by later exit * evidence: another period exiting after that join, or a newer authoritative roster that - * omits the member. When rosters and Updates agree this is simply "some period is open", - * the projection the schema-v13 upgrade repairs. + * omits the member. When rosters and Updates agree this is simply "some period is open". + * Any change to this rule must raise [ACTIVE_STATE_RULE_VERSION]. */ private fun effectiveOpenPeriod(uid: String): String = """ @@ -437,5 +437,34 @@ internal object MembershipRows { db.execSQL(ACTIVE_STATE_UPDATE) } + /** + * Guarantees that every stored active flag was derived at least once with the current rule, + * independent of the schema version: a v13 database written before 2.5.0 changed the rule, + * or a restored backup of one, still holds flags from the older any-open-period rule. + */ + fun ensureActiveStateRule(db: SQLiteDatabase) { + val derivedWith = db.rawQuery( + "SELECT value FROM platoon_maintenance_state WHERE key = ?", + arrayOf(ACTIVE_STATE_RULE_KEY), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + if (derivedWith != null && derivedWith >= ACTIVE_STATE_RULE_VERSION) return + db.inTransaction { + synchronizeAllActiveStates(db) + db.insertWithOnConflict( + "platoon_maintenance_state", + null, + ContentValues().apply { + put("key", ACTIVE_STATE_RULE_KEY) + put("value", ACTIVE_STATE_RULE_VERSION) + }, + SQLiteDatabase.CONFLICT_REPLACE, + ) + } + } + + /** Version 2 is the effective-open-period rule introduced in 2.5.0. */ + const val ACTIVE_STATE_RULE_VERSION = 2L + const val ACTIVE_STATE_RULE_KEY = "active_state_rule" + private const val MAX_BOUND_ARGUMENTS = 500 } 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 3ad357d..5d1da6b 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -43,6 +43,7 @@ internal class PlatoonDatabase private constructor( super.onOpen(db) if (!db.isReadOnly) { PlatoonDatabaseSchema.ensureMaintenanceObjects(db) + MembershipRows.ensureActiveStateRule(db) ActivityIngestion.trimPlatoonActivity(db) } } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt index d6277f4..33c0af9 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt @@ -270,6 +270,13 @@ internal object PlatoonDatabaseMigrations { expectedType = expectedType, ) if (!linked && joinedSource == EvidenceSource.MANUAL) { + deleteSupersededLegacySnapshotEvent( + db, + membershipPeriodId, + uid, + expectedType, + joinedAt, + ) insertLegacyManualBoundaryEvent( db, membershipPeriodId, @@ -292,6 +299,13 @@ internal object PlatoonDatabaseMigrations { expectedType = MemberEventType.LEFT, ) if (!linked && leftSource == EvidenceSource.MANUAL) { + deleteSupersededLegacySnapshotEvent( + db, + membershipPeriodId, + uid, + MemberEventType.LEFT, + leftAt, + ) insertLegacyManualBoundaryEvent( db, membershipPeriodId, @@ -361,6 +375,63 @@ internal object PlatoonDatabaseMigrations { return true } + /** + * A 2.0.0 manual tenure edit left the original roster boundary event behind unlinked; it is + * removed before the manual event is written, so the boundary keeps a single event. The + * member's neighbouring periods, in the id order this loop links them, bound the search. + */ + private fun deleteSupersededLegacySnapshotEvent( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + expectedType: MemberEventType, + boundaryAt: Long, + ) { + val uidArgument = uid.toString() + val periodArgument = membershipPeriodId.toString() + val lowerBound = db.rawQuery( + "SELECT left_at FROM membership_periods WHERE uid = ? AND id < ? ORDER BY id DESC LIMIT 1", + arrayOf(uidArgument, periodArgument), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getNullableLong(0) else null } + val upperBound = db.rawQuery( + "SELECT joined_at FROM membership_periods WHERE uid = ? AND id > ? ORDER BY id LIMIT 1", + arrayOf(uidArgument, periodArgument), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getNullableLong(0) else null } + val candidates = db.rawQuery( + """ + SELECT id, event_type, occurred_at, observed_at, source + FROM member_events + WHERE membership_period_id IS NULL + AND uid = ? + AND source IN (?, ?) + ORDER BY id + """.trimIndent(), + arrayOf(uidArgument, EvidenceSource.SNAPSHOT.name, EvidenceSource.LEGACY_IMPORT.name), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + LegacyEventCandidate( + id = cursor.getLong(0), + type = MemberEventType.valueOf(cursor.getString(1)), + occurredAt = cursor.getNullableLong(2), + observedAt = cursor.getLong(3), + source = EvidenceSource.valueOf(cursor.getString(4)), + ), + ) + } + } + } + val eventId = LegacyEventMigrationPolicy.selectSupersededSnapshotCandidate( + expectedType = expectedType, + boundaryAt = boundaryAt, + lowerBound = lowerBound, + upperBound = upperBound, + candidates = candidates, + ) ?: return + MembershipRows.deleteEvents(db, "id = ?", arrayOf(eventId.toString())) + } + private fun insertLegacyManualBoundaryEvent( db: SQLiteDatabase, membershipPeriodId: Long, diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt index 6781442..a614926 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt @@ -128,7 +128,10 @@ internal object PlatoonReadQueries { null, null, null, - "uid, CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, joined_at, id", + // Chronological: by join, or by withdrawal when the join is unknown; an undated open + // period is the current one and sorts last. Every stored boundary keeps its instant. + "uid, CASE WHEN COALESCE(joined_at, left_at) IS NULL THEN 1 ELSE 0 END, " + + "COALESCE(joined_at, left_at), id", ).use { cursor -> val periods = mutableMapOf>() while (cursor.moveToNext()) { 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 977f036..abb2b5d 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt @@ -70,6 +70,25 @@ object SnapshotComparison { ) } + /** How many recent rosters the latest-change summaries search for a differing baseline. */ + const val COMPARISON_LOOKBACK = 32 + + /** + * The latest roster and the baseline its change is shown against, from [newestFirst]. The + * game resends an unchanged roster when the Platoon page reopens, and each copy is stored, + * so the baseline is the newest earlier roster whose members differ; capture time and file + * are not content. When every fetched roster is identical the oldest one is the baseline, + * and a single roster has none. + */ + fun latestComparablePair(newestFirst: List): Pair? { + val latest = newestFirst.firstOrNull() ?: return null + val earlier = newestFirst.drop(1) + val content = latest.memberContent() + return latest to (earlier.firstOrNull { it.memberContent() != content } ?: earlier.lastOrNull()) + } + + private fun PlatoonSnapshot.memberContent(): Set = members.toSet() + /** A reset between the captures, or an unexplained drop, leaves only the newer counter. */ private fun resettingDelta( previous: Long, diff --git a/app/src/test/java/dev/gf2log/app/management/LegacyEventMigrationPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/LegacyEventMigrationPolicyTest.kt index 50f6960..b25d5d3 100644 --- a/app/src/test/java/dev/gf2log/app/management/LegacyEventMigrationPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/LegacyEventMigrationPolicyTest.kt @@ -103,6 +103,43 @@ class LegacyEventMigrationPolicyTest { ) } + @Test + fun `a manual legacy boundary supersedes the nearest roster event in its gap`() { + val candidates = listOf( + candidate(1, MemberEventType.JOINED, occurredAt = null, observedAt = BOUNDARY_AT - 5, source = EvidenceSource.SNAPSHOT), + candidate(2, MemberEventType.JOINED, occurredAt = null, observedAt = BOUNDARY_AT + 50, source = EvidenceSource.SNAPSHOT), + candidate(3, MemberEventType.LEFT, occurredAt = null, observedAt = BOUNDARY_AT, source = EvidenceSource.SNAPSHOT), + ) + + assertEquals( + 1L, + LegacyEventMigrationPolicy.selectSupersededSnapshotCandidate( + MemberEventType.REJOINED, + BOUNDARY_AT, + lowerBound = null, + upperBound = null, + candidates = candidates, + ), + ) + } + + @Test + fun `a roster event outside the period's gap or of the other side is never superseded`() { + val outside = candidate(1, MemberEventType.JOINED, occurredAt = null, observedAt = BOUNDARY_AT + 100, source = EvidenceSource.SNAPSHOT) + val withdrawal = candidate(2, MemberEventType.LEFT, occurredAt = null, observedAt = BOUNDARY_AT, source = EvidenceSource.SNAPSHOT) + val manual = candidate(3, MemberEventType.JOINED, source = EvidenceSource.MANUAL) + + assertNull( + LegacyEventMigrationPolicy.selectSupersededSnapshotCandidate( + MemberEventType.JOINED, + BOUNDARY_AT, + lowerBound = BOUNDARY_AT - 10, + upperBound = BOUNDARY_AT + 100, + candidates = listOf(outside, withdrawal, manual), + ), + ) + } + private fun candidate( id: Long, type: MemberEventType, diff --git a/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonBaselineTest.kt b/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonBaselineTest.kt new file mode 100644 index 0000000..ea9f877 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/SnapshotComparisonBaselineTest.kt @@ -0,0 +1,63 @@ +package dev.gf2log.app.management + +import java.time.Instant +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class SnapshotComparisonBaselineTest { + private val zone = ZoneId.of("Asia/Seoul") + + @Test + fun `an unchanged resend is skipped as the baseline`() { + val a = snapshot(1, "2026-08-01T00:00:00Z", member(1, "One"), member(2, "Two")) + val b = snapshot(2, "2026-08-01T06:00:00Z", member(1, "One"), member(3, "Three")) + val resent = snapshot(3, "2026-08-01T06:00:05Z", member(1, "One"), member(3, "Three")) + + val (latest, baseline) = requireNotNull( + SnapshotComparison.latestComparablePair(listOf(resent, b, a)), + ) + + assertSame(resent, latest) + assertSame(a, baseline) + val comparison = SnapshotComparison.compare(requireNotNull(baseline), latest, zone) + assertEquals(listOf(3L), comparison.joined.map(SnapshotMember::uid)) + assertEquals(listOf(2L), comparison.left.map(SnapshotMember::uid)) + } + + @Test + fun `identical rosters fall back to the oldest fetched one`() { + val first = snapshot(1, "2026-08-01T00:00:00Z", member(1, "One")) + val second = snapshot(2, "2026-08-01T01:00:00Z", member(1, "One")) + val third = snapshot(3, "2026-08-01T02:00:00Z", member(1, "One")) + + val pair = requireNotNull(SnapshotComparison.latestComparablePair(listOf(third, second, first))) + + assertSame(third, pair.first) + assertSame(first, pair.second) + } + + @Test + fun `a single roster has no baseline and no roster has no pair`() { + val only = snapshot(1, "2026-08-01T00:00:00Z", member(1, "One")) + + assertNull(requireNotNull(SnapshotComparison.latestComparablePair(listOf(only))).second) + assertNull(SnapshotComparison.latestComparablePair(emptyList())) + } + + private fun snapshot(id: Long, time: String, vararg members: SnapshotMember) = + PlatoonSnapshot(id = id, capturedAt = Instant.parse(time), members = members.toList(), sourceFile = "roster-$id.csv") + + private fun member(uid: Long, name: String) = SnapshotMember( + uid = uid, + name = name, + level = 60, + weeklyMerit = 100, + totalMerit = 1_000, + highScore = 0, + totalScore = 0, + lastLogin = 1_700_000_000, + ) +} From 84d4a7c81ddeb8bfccc7f0118c4607abd8f55bdd Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:17:41 +0900 Subject: [PATCH 51/98] Document membership evidence rules and Daily Patrol sources 21935 802001/801005 rows are name-only supporting history; only 21960 kind 8 supplies Daily Patrol evidence. Also documents the nickname, search, activity-dating, late Updates, open-period, future-date, retention and active-state rules changed in this release. Findings: data-doc-contracts-7 --- docs/ARCHITECTURE.md | 18 +++++++++----- docs/PLATOON_CAPTURE.md | 12 +++++---- docs/PLATOON_MANAGEMENT.md | 50 ++++++++++++++++++++++++++++++-------- 3 files changed, 59 insertions(+), 21 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae40198..2fa4263 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -190,8 +190,10 @@ timestamp but no UID, so a fact is linked only when nearby roster snapshots map the name to exactly one member. Ambiguous duplicate names remain stored but unresolved. -Observed action `802001` is deduplicated to one Daily Patrol fact per UID and -matching 05:00-based game day. Membership boundaries remain available as +Only the UID-safe 21960 Updates kind `8` supplies Daily Patrol evidence, +deduplicated to one fact per UID and 05:00-based game day; 21935 `802001` and +`801005` rows are name-only supporting history and never count as Daily Patrol. +Membership boundaries remain available as UID-safe 21917 observation facts. Payload 21960 supplies exact UID and event timestamps for Join, Withdraw, and Remove boundaries; those individual boundaries are immutable. Manual boundaries remain editable and can coexist @@ -225,13 +227,17 @@ SQLite work. Within that cap, `WeeklyHistoryWorkPolicy` adds the listed neighbouring weeks that read an instant across a 05:00 reset (the closed week of an exact-reset roster, the calendar-day Join / Withdraw week, and later weeks up to the next stored roster), so their stored revisions never lag the -live projection. SQLite retains the newest 10,000 activity -facts, and one ingestion resolves at most 250 unresolved facts. A database-local +live projection. SQLite retains the newest 10,000 21935 activity +facts; exact 21960 Daily Patrol facts are outside that cap. One ingestion +resolves at most 250 unresolved facts. A database-local cursor rotates those bounded batches through the complete retained backlog. Schema v11 owns the rotation cursor and a global `(captured_at, id)` retention index. Schema v13 repairs the derived active-member projection from canonical -membership periods during upgrade; supported older backups are upgraded before -strict current-schema validation. +membership periods during upgrade, and every writable open re-derives it once +more whenever the rule's `active_state_rule` version changes. Schema v14 marks +activity-dated roster boundaries as `ACTIVITY` evidence and folds duplicate +open periods; supported older backups are upgraded before strict +current-schema validation. These limits are enforced again at repository and persistence boundaries so a future caller cannot bypass the protocol-layer checks. User-entered text has the same treatment: `ManualTextPolicy` trims and then rejects a member name diff --git a/docs/PLATOON_CAPTURE.md b/docs/PLATOON_CAPTURE.md index 74eff76..953ddeb 100644 --- a/docs/PLATOON_CAPTURE.md +++ b/docs/PLATOON_CAPTURE.md @@ -42,11 +42,13 @@ Entry.member_name = field 5 ``` `occurred_at` is Unix time in seconds. Observed `802001` entries correlate with -the in-game Daily Patrol-completion/update line and are collapsed to one fact -per UID and game day. Duplicate `802001` rows do not mean that Daily Patrol was -completed twice. `801005` is a correlated companion daily reward/reset record; -its exact user-facing label remains provisional, so it is retained in parsed -history but is not counted independently. +the in-game Daily Patrol-completion/update line, and `801005` is a correlated +companion daily reward/reset record whose exact user-facing label remains +provisional. A 21935 activity carries a name but no UID, so both are kept only +as name-resolved supporting history and never count as Daily Patrol. Daily +Patrol evidence comes only from the UID-safe 21960 Updates kind `8`, which the +weekly table deduplicates to one fact per UID and 05:00-based game day; +duplicate rows never mean that Daily Patrol was completed twice. `Summary.id` is a unique, monotonic server record/cursor identifier, not a member UID. `Entry.kind` is an event variant/state discriminator: values 1, 2, diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 56a5d7f..74bbfa3 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -101,7 +101,10 @@ transitions. ## Snapshots and weekly tables - Every Platoon Members payload is stored as a structured snapshot keyed by its - capture instant. + capture instant. Home's latest-roster row and Snapshot Comparison compare the + latest roster with the newest earlier roster whose members differ, so an + unchanged roster resent when the Platoon page reopens never hides the change + the session captured. - Every supplied Platoon Activity entry is stored as an immutable timestamped fact. Only UID-safe Updates kind `8` / action `802001` supplies exact Daily Patrol evidence; a missing event never proves that Daily Patrol was skipped. @@ -167,7 +170,12 @@ transitions. - UID is the stable member identity; a display name can change over time. - A custom nickname is editable and immediately becomes the display name in - Platoon management; the personal note remains private to member details. + Platoon management; the personal note remains private to member details. A + blank nickname, or one equal to the in-game name, stores no nickname, so the + member keeps following later in-game renames; saving only the note never + turns the current in-game name into a nickname. +- Member search matches the display name, the in-game name behind a nickname, + and the UID, with or without its leading `#`. - Manual text is bounded where it is stored, not only by input fields. After trimming, a custom nickname or manually added member name holds at most 256 characters (the roster name limit), and a private member note, membership-period @@ -179,11 +187,16 @@ transitions. join time unless the user supplies one. - A UID added between consecutive snapshots opens a join or rejoin membership period. - A UID removed between consecutive snapshots closes the active membership period but - never deletes the member or prior snapshots. + never deletes the member or prior snapshots. Any other period still open for + that UID is closed at the same observation, after a duplicate open period that + only roster or activity evidence supports has been folded into the stronger + one, so the UID keeps no open period. - A returning UID opens a new membership period. All previous join/withdraw periods remain available. - Manual edits are accepted only when every period ends after it starts, no two - fully dated periods overlap, and at most one period remains open. An absent + fully dated periods overlap, at most one period remains open, and no entered + join or withdrawal lies in the future (one hour of device clock skew is + allowed, and the date picker stops at today). An absent snapshot-derived start remains unknown rather than being treated as an infinitely old boundary. Snapshot and exact boundaries are dated in the Platoon's game timezone for this check, the same calendar manual dates use, @@ -192,7 +205,9 @@ transitions. periods inside the same transaction with one rule: a member is active while an open period remains effective. An open period started by an exact Updates join is superseded by a later recorded exit or by a newer roster that omits the - member. Schema-v13 upgrade repairs any stale derived flag with the same rule. + member. Opening a database re-derives every stale flag once whenever this + rule's version changes (platoon_maintenance_state key `active_state_rule`), + including databases already at schema v13 and restored backups. - A roster CSV imported after newer captures still contributes historical presence spans. The replay creates missing inactive members and inferred withdrawal/rejoin periods without replacing manual or exact Updates evidence. @@ -209,15 +224,29 @@ transitions. 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 + the change, never becomes the boundary. Both the roster-time and the later + activity-time correlation apply this window. At roster time the change's + name-matched facts must share one instant; later, a UID-resolved join is + dated by the earliest admitted fact and a withdrawal by the latest, the + tightest bound each proves. A fact already resolved to another UID never + dates a boundary. Such a boundary is stored and labelled as dated by activity + (source `ACTIVITY`, schema v14), not as exact Updates evidence, and it stays + editable. 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. + correlated safely; 21935 contains names but no UID. A 21960 boundary replaces + the roster-inferred or activity-dated boundary of the same change, within 48 + hours or however late the Updates feed arrives as long as no roster was + captured between the two, so one change keeps one period and one event. - Exact packet-linked boundary fields are immutable while editing. Users can add or edit manual membership periods, including historical withdrawn members not present in the incremental activity response; deleting an entire period requires the separate confirmation flow described below. -- Membership-period numbers are assigned by join date from oldest to newest and are - recalculated after manual additions or edits. +- Membership-period numbers are assigned by join date from oldest to newest, using the + withdrawal date when the join is unknown, and are recalculated after manual additions or + edits. +- A manually added withdrawn member needs a game UID from 1 to 4294967295. +- Deleting a member also removes unresolved activity facts under a name that no + other member ever held, so they cannot reattach to a member re-added later. ## Weekly history consistency @@ -282,7 +311,8 @@ software. Untrusted evidence is bounded at every persistence boundary. A 21960 Updates entry is dropped whole when any name exceeds 256 characters or it lists more than 256 members. A roster import must contain no more than 256 unique UIDs, an activity payload contributes at most 250 distinct observations, and the database keeps the newest 10,000 -activity facts. If a newly selected roster CSV fails parsing, duplicate-UID +21935 activity facts. Exact 21960 Daily Patrol facts do not count toward that +cap, so newer activity never evicts a week's only Daily Patrol evidence. If a newly selected roster CSV fails parsing, duplicate-UID validation, ingestion, or chronological reconciliation, its newly retained file is deleted so a failed import cannot poison every later reconciliation. The entire selected-file reconciliation runs under one outer SQLite transaction, so From 0319b73b1c933ceae2655d9281fca35ef61ed50a Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sat, 7 Nov 2026 00:23:16 +0900 Subject: [PATCH 52/98] Harden Platoon storage against crafted restores, corruption and races Restore - Copy validated backup rows into a database the current schema code creates instead of installing the selected file, and require exact app DDL afterwards, so CHECK, ON CONFLICT, COLLATE, STRICT or WITHOUT ROWID clauses never become the live schema (sec-storage-1). - Bound restored weekly history: at most 15 revisions per week, bounded payloads, SHA-256 fingerprints and same-week selections; undecodable revisions are dropped from the rebuilt copy (sec-doc-claims-3). - Delete a stale rollback copy before a restore transaction begins (data-deletion-remnants-m4). Damage - Move a corrupt database aside under .corrupt-