diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c1054..2e293ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,9 +51,10 @@ All notable changes to mobileGF2logger are documented here. parser, order known same-day joins by instant, bound manual weekly notes, preserve captured member names, replace timezone-derived history atomically, and accept `21905` checklist evidence only after identity validation. -- Bound the profile registry, profile metadata, and pre-identity flow buffer; - reject invalid restores before metadata changes, preserve the selected import - scope through preview/apply, and restore the matching client-region routing. +- Bound the profile registry, profile metadata, and pre-identity quarantine with + both per-flow and aggregate decoded-payload caps; reject invalid restores before + metadata changes, preserve the selected import scope through preview/apply, and + restore the matching client-region routing. - Quarantine identity-changing or admission-failed flows, admit at most one new profile per client per user-started capture, and let users forget selector metadata without deleting the isolated Platoon data. diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index e196aaf..ec50bb9 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -1106,7 +1106,13 @@ class WeeklyReportActivity : LocalizedActivity() { setTextColor(getColor(R.color.accent_text)) background = ModernUi.panelBackground(context, emphasized = true) setPadding(dp(8), dp(8), dp(8), dp(8)) - }, LinearLayout.LayoutParams(dp(76), dp(48)).apply { + minWidth = dp(76) + minHeight = dp(48) + maxLines = 1 + }, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { marginEnd = dp(12) }) addView(TextView(context).apply { 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 b0a2a42..e79dd47 100644 --- a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt +++ b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt @@ -1,43 +1,53 @@ package dev.gf2log.app.capture /** Bounded pre-identity quarantine; overflow permanently rejects that flow until closure. */ -internal class BoundedFlowPayloadBuffer(private val maxItemsPerFlow: Int) { +internal class BoundedFlowPayloadBuffer( + private val maxItemsPerFlow: Int, + private val maxTotalItems: Int, +) { private val pending = mutableMapOf>() private val rejected = mutableSetOf() + private var totalItems = 0 init { require(maxItemsPerFlow > 0) + require(maxTotalItems > 0) } fun offer(flowId: Long, item: T): OfferResult { if (flowId in rejected) return OfferResult.REJECTED val items = pending.getOrPut(flowId, ::ArrayDeque) - if (items.size >= maxItemsPerFlow) { - pending.remove(flowId) - rejected += flowId + if (items.size >= maxItemsPerFlow || totalItems >= maxTotalItems) { + reject(flowId) return OfferResult.OVERFLOW } items.addLast(item) + totalItems += 1 return OfferResult.ACCEPTED } - fun take(flowId: Long): List = pending.remove(flowId)?.toList().orEmpty() + fun take(flowId: Long): List { + val items = pending.remove(flowId) ?: return emptyList() + totalItems -= items.size + return items.toList() + } fun isRejected(flowId: Long): Boolean = flowId in rejected fun reject(flowId: Long) { - pending.remove(flowId) + totalItems -= pending.remove(flowId)?.size ?: 0 rejected += flowId } fun remove(flowId: Long) { - pending.remove(flowId) + totalItems -= pending.remove(flowId)?.size ?: 0 rejected.remove(flowId) } fun clear() { pending.clear() rejected.clear() + totalItems = 0 } enum class OfferResult { ACCEPTED, OVERFLOW, REJECTED } 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 a118ab1..4489014 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -49,6 +49,7 @@ class CaptureVpnService : VpnService() { private val pendingAdmissionByFlow = ConcurrentHashMap() private val pendingFlowPayloads = BoundedFlowPayloadBuffer( MAX_PENDING_PAYLOADS_PER_FLOW, + MAX_PENDING_PAYLOADS_TOTAL, ) private val decodedPayloadCount = AtomicLong() private val observedPayloadBytes = AtomicLong() @@ -968,6 +969,7 @@ class CaptureVpnService : VpnService() { private const val TRAFFIC_REPORT_BYTES = 64 * 1024 private const val CAPTURE_ONCE_GRACE_MILLIS = 60_000L private const val MAX_PENDING_PAYLOADS_PER_FLOW = 32 + private const val MAX_PENDING_PAYLOADS_TOTAL = 128 private val REQUIRED_CAPTURE_TYPES = setOf( Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, 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 3257c0d..795417a 100644 --- a/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/BoundedFlowPayloadBufferTest.kt @@ -7,7 +7,7 @@ import org.junit.Test class BoundedFlowPayloadBufferTest { @Test fun overflowDiscardsAndRejectsUntilFlowRemoval() { - val buffer = BoundedFlowPayloadBuffer(2) + val buffer = BoundedFlowPayloadBuffer(2, 4) assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(7, "a")) assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(7, "b")) assertEquals(BoundedFlowPayloadBuffer.OfferResult.OVERFLOW, buffer.offer(7, "c")) @@ -24,11 +24,40 @@ class BoundedFlowPayloadBufferTest { @Test fun identityTakesOneFlowWithoutTouchingAnother() { - val buffer = BoundedFlowPayloadBuffer(2) + val buffer = BoundedFlowPayloadBuffer(2, 4) buffer.offer(1, "one") buffer.offer(2, "two") assertEquals(listOf("one"), buffer.take(1)) assertEquals(listOf("two"), buffer.take(2)) } + + @Test + fun aggregateOverflowRejectsOnlyTheFlowThatExceededTheGlobalBudget() { + val buffer = BoundedFlowPayloadBuffer(4, 3) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(1, "one-a")) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(1, "one-b")) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(2, "two")) + + assertEquals(BoundedFlowPayloadBuffer.OfferResult.OVERFLOW, buffer.offer(1, "one-c")) + assertTrue(buffer.isRejected(1)) + assertTrue(buffer.take(1).isEmpty()) + assertEquals(listOf("two"), buffer.take(2)) + + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(3, "three")) + assertEquals(listOf("three"), buffer.take(3)) + } + + @Test + fun removalAndClearReleaseAggregateCapacity() { + val buffer = BoundedFlowPayloadBuffer(2, 2) + buffer.offer(1, "one") + buffer.offer(2, "two") + buffer.remove(1) + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(3, "three")) + + buffer.clear() + assertEquals(BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, buffer.offer(4, "four")) + assertEquals(listOf("four"), buffer.take(4)) + } }