From b132432e862dd35a72a4dac9f6e8491edea76974 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 10:51:55 +0900 Subject: [PATCH 001/125] fix: release native capture references and classify flows once Delete global references while the capture thread is still attached and reap every capture thread exactly once, so a loop that exits on its own no longer blocks the next start. Classify TLS/HTTP only on a flow's first payload, notify Java only about TCP flows, and drop the always-false sent flag from the payload callback. --- app/src/main/cpp/gf2capture.c | 103 ++++++++++++------ .../gf2log/app/capture/CaptureVpnService.kt | 7 +- .../gf2log/app/capture/NativeCaptureBridge.kt | 3 +- 3 files changed, 72 insertions(+), 41 deletions(-) diff --git a/app/src/main/cpp/gf2capture.c b/app/src/main/cpp/gf2capture.c index 20e6534..39dcea2 100644 --- a/app/src/main/cpp/gf2capture.c +++ b/app/src/main/cpp/gf2capture.c @@ -32,6 +32,8 @@ typedef struct flow_state { int64_t id; + bool tracked; + bool classified; bool inspect_payload; } flow_state_t; @@ -109,11 +111,17 @@ static void notify_payload( return; } - if (is_web_transport(payload, payload_length)) { - flow->inspect_payload = false; - return; + // Like the reference IgnoreTls addon, decide once at connection start. Later game + // segments may begin with TLS-like bytes and must not silently disable inspection. + if (!flow->classified) { + flow->classified = true; + if (is_web_transport(payload, payload_length)) { + flow->inspect_payload = false; + return; + } } + // Outgoing plaintext is used only for classification. if (is_sent) { return; } @@ -138,7 +146,6 @@ static void notify_payload( context->listener, context->payload_method, (jlong) flow->id, - is_sent ? JNI_TRUE : JNI_FALSE, bytes); clear_java_exception(context, "payload delivery"); } @@ -255,11 +262,16 @@ static int connection_open_callback(zdtun_t *tunnel, zdtun_conn_t *connection) { return 1; } + const zdtun_5tuple_t *tuple = zdtun_conn_get_5tuple(connection); flow->id = ++context->next_flow_id; - flow->inspect_payload = zdtun_conn_get_5tuple(connection)->ipproto == IPPROTO_TCP; + // Only TCP streams can carry game frames, so other flows never reach Java. + flow->tracked = tuple->ipproto == IPPROTO_TCP; + flow->inspect_payload = flow->tracked; zdtun_conn_set_userdata(connection, flow); + if (!flow->tracked) { + return 0; + } - const zdtun_5tuple_t *tuple = zdtun_conn_get_5tuple(connection); const int family = tuple->ipver == 4 ? AF_INET : AF_INET6; char local_address[INET6_ADDRSTRLEN] = {0}; char remote_address[INET6_ADDRSTRLEN] = {0}; @@ -294,7 +306,7 @@ static void connection_close_callback(zdtun_t *tunnel, const zdtun_conn_t *conne return; } - if (context->env != NULL) { + if (flow->tracked && context->env != NULL) { (*context->env)->CallVoidMethod( context->env, context->listener, @@ -349,26 +361,37 @@ static void notify_traffic(capture_context_t *context) { clear_java_exception(context, "traffic update"); } -static void release_context(capture_context_t *context) { - if (context->env != NULL) { +/** Releases whatever the context still owns; global references need an attached env. */ +static void release_context(JNIEnv *env, capture_context_t *context) { + if (env != NULL) { if (context->listener != NULL) { - (*context->env)->DeleteGlobalRef(context->env, context->listener); + (*env)->DeleteGlobalRef(env, context->listener); + context->listener = NULL; } if (context->vpn_service != NULL) { - (*context->env)->DeleteGlobalRef(context->env, context->vpn_service); + (*env)->DeleteGlobalRef(env, context->vpn_service); + context->vpn_service = NULL; } } if (context->tun_fd >= 0) { close(context->tun_fd); + context->tun_fd = -1; } } +/** Joins a capture thread exactly once, then frees its context. */ +static void reap_context(JNIEnv *env, capture_context_t *context) { + pthread_join(context->thread, NULL); + release_context(env, context); + free(context); +} + static void *capture_thread_main(void *argument) { capture_context_t *context = (capture_context_t *) argument; if ((*context->vm)->AttachCurrentThread(context->vm, &context->env, NULL) != JNI_OK) { LOGE("Unable to attach capture thread to JVM"); - atomic_store(&context->running, false); + context->env = NULL; goto cleanup; } @@ -465,24 +488,22 @@ static void *capture_thread_main(void *argument) { LOGI("Native packet loop stopped"); detach: - if (context->env != NULL && context->listener != NULL && context->stopped_method != NULL) { + if (context->listener != NULL && context->stopped_method != NULL) { (*context->env)->CallVoidMethod( context->env, context->listener, context->stopped_method); clear_java_exception(context, "capture stopped notification"); } + // Global references must be deleted while this thread is still attached. + release_context(context->env, context); (*context->vm)->DetachCurrentThread(context->vm); context->env = NULL; cleanup: - release_context(context); - pthread_mutex_lock(&context_mutex); - if (active_context == context) { - active_context = NULL; - } - pthread_mutex_unlock(&context_mutex); - free(context); + // Closing the TUN descriptor now lets Android tear down the VPN before the thread is reaped. + release_context(NULL, context); + atomic_store(&context->running, false); return NULL; } @@ -496,9 +517,21 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( (void) bridge; pthread_mutex_lock(&context_mutex); - if (active_context != NULL) { + capture_context_t *previous = active_context; + if (previous != NULL) { + if (atomic_load(&previous->running) || pthread_equal(pthread_self(), previous->thread)) { + pthread_mutex_unlock(&context_mutex); + return JNI_FALSE; + } + // The previous loop exited on its own and was never stopped; reap it before restarting. + active_context = NULL; pthread_mutex_unlock(&context_mutex); - return JNI_FALSE; + reap_context(env, previous); + pthread_mutex_lock(&context_mutex); + if (active_context != NULL) { + pthread_mutex_unlock(&context_mutex); + return JNI_FALSE; + } } capture_context_t *context = calloc(1, sizeof(capture_context_t)); @@ -518,8 +551,7 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( context->vpn_service = (*env)->NewGlobalRef(env, vpn_service); context->listener = (*env)->NewGlobalRef(env, listener); if (context->tun_fd < 0 || context->vpn_service == NULL || context->listener == NULL) { - context->env = env; - release_context(context); + release_context(env, context); free(context); pthread_mutex_unlock(&context_mutex); return JNI_FALSE; @@ -528,7 +560,7 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( jclass service_class = (*env)->GetObjectClass(env, vpn_service); jclass listener_class = (*env)->GetObjectClass(env, listener); context->protect_method = (*env)->GetMethodID(env, service_class, "protect", "(I)Z"); - context->payload_method = (*env)->GetMethodID(env, listener_class, "onPayload", "(JZ[B)V"); + context->payload_method = (*env)->GetMethodID(env, listener_class, "onPayload", "(J[B)V"); context->flow_opened_method = (*env)->GetMethodID( env, listener_class, @@ -546,23 +578,20 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( context->stopped_method == NULL || (*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); - context->env = env; - release_context(context); + release_context(env, context); free(context); pthread_mutex_unlock(&context_mutex); return JNI_FALSE; } atomic_init(&context->running, true); - active_context = context; if (pthread_create(&context->thread, NULL, capture_thread_main, context) != 0) { - active_context = NULL; - context->env = env; - release_context(context); + release_context(env, context); free(context); pthread_mutex_unlock(&context_mutex); return JNI_FALSE; } + active_context = context; pthread_mutex_unlock(&context_mutex); return JNI_TRUE; @@ -570,7 +599,6 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStart( JNIEXPORT void JNICALL Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStop(JNIEnv *env, jobject bridge) { - (void) env; (void) bridge; pthread_mutex_lock(&context_mutex); @@ -581,10 +609,13 @@ Java_dev_gf2log_app_capture_NativeCaptureBridge_nativeStop(JNIEnv *env, jobject } atomic_store(&context->running, false); - pthread_t thread = context->thread; + if (pthread_equal(pthread_self(), context->thread)) { + // A callback cannot join its own thread; the next start or stop reaps it. + pthread_mutex_unlock(&context_mutex); + return; + } + active_context = NULL; pthread_mutex_unlock(&context_mutex); - if (!pthread_equal(pthread_self(), thread)) { - pthread_join(thread, NULL); - } + reap_context(env, context); } 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 4489014..3f0b002 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -274,8 +274,8 @@ class CaptureVpnService : VpnService() { ) } - override fun onPayload(flowId: Long, isSent: Boolean, payload: ByteArray) { - enqueuePayload(flowId, isSent, payload) + override fun onPayload(flowId: Long, payload: ByteArray) { + enqueuePayload(flowId, payload) } override fun onFlowClosed(flowId: Long) { @@ -307,9 +307,8 @@ class CaptureVpnService : VpnService() { updateNotification("Capturing selected game traffic") } - private fun enqueuePayload(flowId: Long, isSent: Boolean, payload: ByteArray) { + private fun enqueuePayload(flowId: Long, payload: ByteArray) { if (payload.isEmpty()) return - if (isSent) return if (flowId in taintedFlows) return if (!submitParserTask { if (flowId in taintedFlows) return@submitParserTask diff --git a/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt b/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt index 4c114af..6891016 100644 --- a/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt +++ b/app/src/main/java/dev/gf2log/app/capture/NativeCaptureBridge.kt @@ -13,7 +13,8 @@ object NativeCaptureBridge { remotePort: Int, ) - fun onPayload(flowId: Long, isSent: Boolean, payload: ByteArray) + /** Receives server-to-client TCP payload only; outgoing bytes stay native. */ + fun onPayload(flowId: Long, payload: ByteArray) fun onFlowClosed(flowId: Long) From 82975b044c3519ab836ef213f2b93c2201376f53 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 10:52:28 +0900 Subject: [PATCH 002/125] fix: keep unmergeable continuation payloads instead of dropping them A same-type continuation of a single-object payload such as 21905 now emits the pending payload as its own event rather than silently replacing it. Also remove a quarantine ternary whose null branch was unreachable. --- .../dev/gf2log/protocol/Gfl2StreamParser.kt | 20 +++++----- .../gf2log/protocol/Gfl2StreamParserTest.kt | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt index e4b220a..092d0de 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt @@ -142,9 +142,10 @@ class Gfl2StreamParser( ) { val pending = pendingPayload if (pending != null) { - if (pending.type == type && + val continues = pending.type == type && (pending.previousMessageId == 0 || pending.previousMessageId == messageId) - ) { + val merged = if (continues) merge(pending.data, data) else null + if (continues && merged != null) { val accumulatedBytes = pending.accumulatedPayloadBytes + payloadBytes.toLong() val continuationCount = pending.continuationCount + 1 if ( @@ -162,7 +163,7 @@ class Gfl2StreamParser( ) return } - pending.data = merge(pending.data, data) + pending.data = merged pending.previousMessageId = messageId pending.isEndOfMessage = isEndOfMessage pending.accumulatedPayloadBytes = accumulatedBytes @@ -170,6 +171,8 @@ class Gfl2StreamParser( if (messageId != 0 && isEndOfMessage) emitPending(events) return } + // A different dataset, or a continuation of a single-object payload such as + // 21905, keeps the earlier payload intact as its own event. emitPending(events) } @@ -177,11 +180,7 @@ class Gfl2StreamParser( events += ParseEvent.Payload(ParsedPayload(messageId, type, true, data)) } else { if (payloadBytes > maximumPendingPayloadBytes) { - quarantinedPayload = if (messageId != 0 && isEndOfMessage) { - null - } else { - QuarantinedPayload(type, messageId) - } + quarantinedPayload = QuarantinedPayload(type, messageId) events += ParseEvent.Warning( "Discarded payload $type continuation sequence exceeding parser limits", ) @@ -229,7 +228,8 @@ class Gfl2StreamParser( pendingPayload = null } - private fun merge(first: GameData, second: GameData): GameData = when { + /** 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) @@ -246,7 +246,7 @@ class Gfl2StreamParser( PlatoonUpdatesData(first.entries + second.entries) first is FormationsData && second is FormationsData -> FormationsData(first.formations + second.formations) - else -> second + else -> null } private fun readUInt16Le(bytes: ByteArray, offset: Int): Int = diff --git a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt index 3d2ea3f..aaaf193 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt @@ -211,6 +211,42 @@ class Gfl2StreamParserTest { assertEquals(listOf(1u, 2u), members.map { it.uid }) } + @Test + fun unmergeableProfileContinuationKeepsBothPayloads() { + val parser = Gfl2StreamParser() + val first = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, platoonProfilePayload(11uL, "First")), + ) + val last = outerMessage( + 42, + payload(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, platoonProfilePayload(11uL, "Second")), + ) + + assertTrue(parser.accept(first).isEmpty()) + val payloads = parser.accept(last).filterIsInstance() + + assertEquals( + listOf("First", "Second"), + payloads.map { (it.value.data as PlatoonProfileData).platoonName }, + ) + assertEquals(listOf(0, 42), payloads.map { it.value.messageId }) + assertTrue(parser.finish().isEmpty()) + } + + @Test + fun unmergeablePendingPayloadSurvivesALaterContinuation() { + val parser = Gfl2StreamParser() + val profile = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, platoonProfilePayload(11uL, "Only")), + ) + + assertTrue(parser.accept(profile).isEmpty()) + assertTrue(parser.accept(profile).filterIsInstance().size == 1) + assertEquals(1, parser.finish().filterIsInstance().size) + } + @Test fun pendingContinuationIsEmittedWhenFlowFinishes() { val parser = Gfl2StreamParser() @@ -414,6 +450,9 @@ class Gfl2StreamParserTest { return messageField(1, member) } + private fun platoonProfilePayload(id: ULong, name: String): ByteArray = + messageField(1, uintField(1, id) + stringField(2, name)) + private fun formationsPayload(): ByteArray { val doll = uintField(1, 12uL) + uintField(2, 900uL) + From fca423e58379cb80f02182140682cb8a40cb7acf Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 10:54:34 +0900 Subject: [PATCH 003/125] fix: serialize parsed-packet history per directory Every store for the same canonical directory now shares one process-wide lock, listing no longer deletes files, and only saving rotates recent history. Saved history no longer borrows the recent-history limit; both stores share one path-safe directory helper, one atomic write utility, and one log-time formatter. --- .../dev/gf2log/app/history/AtomicFiles.kt | 28 +++++ .../gf2log/app/history/CaptureHistoryStore.kt | 95 ++++------------ .../gf2log/app/history/HistoryDirectory.kt | 66 +++++++++++ .../gf2log/app/history/SavedHistoryStore.kt | 105 +++++++----------- .../app/history/CaptureHistoryStoreTest.kt | 38 +++++++ .../main/java/dev/gf2log/protocol/LogTime.kt | 14 +++ 6 files changed, 207 insertions(+), 139 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt create mode 100644 app/src/main/java/dev/gf2log/app/history/HistoryDirectory.kt create mode 100644 protocol/src/main/java/dev/gf2log/protocol/LogTime.kt diff --git a/app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt b/app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt new file mode 100644 index 0000000..e61fd9f --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/history/AtomicFiles.kt @@ -0,0 +1,28 @@ +package dev.gf2log.app.history + +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +internal object AtomicFiles { + /** Moves [source] to [destination], atomically when the filesystem supports it. */ + fun move(source: File, destination: File) { + try { + Files.move(source.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source.toPath(), destination.toPath()) + } + } + + /** Writes through a sibling temporary file so readers never observe partial content. */ + fun write(destination: File, content: ByteArray, temporaryPrefix: String) { + val temporary = File.createTempFile(temporaryPrefix, ".tmp", destination.parentFile) + try { + temporary.writeBytes(content) + move(temporary, destination) + } finally { + temporary.delete() + } + } +} 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 4531b4e..fc50267 100644 --- a/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt +++ b/app/src/main/java/dev/gf2log/app/history/CaptureHistoryStore.kt @@ -1,99 +1,51 @@ package dev.gf2log.app.history +import dev.gf2log.protocol.LogTime import dev.gf2log.protocol.ParsedPayloadTextFormatter import dev.gf2log.protocol.model.ParsedPayload import java.io.File -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption import java.time.Clock import java.time.Instant import java.time.ZoneId -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter import java.util.concurrent.atomic.AtomicLong +/** Recent parsed-packet history; each save rotates out the oldest entries beyond [MAX_ENTRIES]. */ class CaptureHistoryStore( - private val directory: File, + directory: File, private val clock: Clock = Clock.systemUTC(), - private val displayZone: ZoneId? = null, + displayZone: ZoneId? = null, ) { - @Synchronized - fun save(payload: ParsedPayload): Entry { - check(directory.isDirectory || directory.mkdirs()) { "Unable to create history directory" } + private val entries = HistoryDirectory(directory, displayZone) + + fun save(payload: ParsedPayload): Entry = synchronized(entries.lock) { + entries.ensureExists("history") val instant = Instant.now(clock) - val capturedAt = LOG_TIME_FORMAT.format(instant) - val id = "${instant.toEpochMilli()}_${payload.payloadType}_${sequence.incrementAndGet()}.txt" - val destination = File(directory, id) - val temporary = File.createTempFile("packet_", ".tmp", directory) - try { - val content = ParsedPayloadTextFormatter.format(payload, capturedAt) - .toByteArray(Charsets.UTF_8) - require(content.size <= MAX_ENTRY_BYTES) { - "Parsed-packet history entry exceeds the storage limit" - } - temporary.writeBytes(content) - try { - Files.move( - temporary.toPath(), - destination.toPath(), - StandardCopyOption.ATOMIC_MOVE, - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move(temporary.toPath(), destination.toPath()) - } - } finally { - temporary.delete() + val content = ParsedPayloadTextFormatter.format(payload, LogTime.format(instant)) + .toByteArray(Charsets.UTF_8) + require(content.size <= MAX_ENTRY_BYTES) { + "Parsed-packet history entry exceeds the storage limit" } + val id = "${instant.toEpochMilli()}_${payload.payloadType}_${sequence.incrementAndGet()}" + + ".${HistoryDirectory.ENTRY_EXTENSION}" + val destination = File(entries.directory, id) + AtomicFiles.write(destination, content, "packet_") destination.setLastModified(instant.toEpochMilli()) trimToLimit() - return destination.toEntry() + entries.toEntry(destination) } - @Synchronized - fun list(): List { - directory.mkdirs() - trimToLimit() - return historyFiles() - .sortedWith(compareByDescending { it.lastModified() }.thenByDescending { it.name }) - .map { it.toEntry() } - } + fun list(): List = entries.list() - @Synchronized - fun read(id: String): String? { - if (id.isBlank() || File(id).name != id) return null - val file = File(directory, id) - if (!file.isFile || file.parentFile?.canonicalFile != directory.canonicalFile) return null - if (file.length() > MAX_ENTRY_BYTES) return null - return file.readText(Charsets.UTF_8) - } + fun read(id: String): String? = entries.read(id) - @Synchronized - fun delete(ids: Collection): Int = ids - .asSequence() - .filter { it.isNotBlank() && File(it).name == it } - .map { File(directory, it) } - .filter { it.isFile && it.parentFile?.canonicalFile == directory.canonicalFile } - .count(File::delete) + fun delete(ids: Collection): Int = entries.delete(ids) private fun trimToLimit() { - val oldestFirst = historyFiles() + val oldestFirst = entries.files() .sortedWith(compareBy { it.lastModified() }.thenBy { it.name }) oldestFirst.take((oldestFirst.size - MAX_ENTRIES).coerceAtLeast(0)).forEach(File::delete) } - private fun historyFiles(): List = directory.listFiles() - .orEmpty() - .filter { it.isFile && it.extension == "txt" } - - private fun File.toEntry(): Entry = Entry( - id = name, - title = TITLE_TIME_FORMAT - .withZone(displayZone ?: ZoneId.systemDefault()) - .format(Instant.ofEpochMilli(lastModified())), - payloadType = name.split('_').getOrNull(1)?.toIntOrNull(), - ) - data class Entry( val id: String, val title: String, @@ -105,10 +57,5 @@ class CaptureHistoryStore( const val MAX_ENTRY_BYTES = 2 * 1024 * 1024 const val HISTORY_DIRECTORY = "capture-history" private val sequence = AtomicLong() - private val LOG_TIME_FORMAT = DateTimeFormatter - .ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") - .withZone(ZoneOffset.UTC) - private val TITLE_TIME_FORMAT = DateTimeFormatter - .ofPattern("yy/MM/dd HH:mm:ss") } } diff --git a/app/src/main/java/dev/gf2log/app/history/HistoryDirectory.kt b/app/src/main/java/dev/gf2log/app/history/HistoryDirectory.kt new file mode 100644 index 0000000..1769335 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/history/HistoryDirectory.kt @@ -0,0 +1,66 @@ +package dev.gf2log.app.history + +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.concurrent.ConcurrentHashMap + +/** + * Path-safe access to one directory of parsed-packet text entries. + * + * Activities and the capture service construct their own stores, so every instance for the same + * canonical directory shares one process-wide lock. Reads never delete; rotation belongs to writers. + */ +internal class HistoryDirectory( + val directory: File, + private val displayZone: ZoneId? = null, +) { + val lock: Any = locks.computeIfAbsent(directory.canonicalPath) { Any() } + + fun ensureExists(description: String) { + check(directory.isDirectory || directory.mkdirs()) { "Unable to create $description directory" } + } + + fun list(): List = synchronized(lock) { + files() + .sortedWith(compareByDescending { it.lastModified() }.thenByDescending { it.name }) + .map(::toEntry) + } + + fun read(id: String): String? = synchronized(lock) { + val file = resolveExisting(id) ?: return null + if (file.length() > CaptureHistoryStore.MAX_ENTRY_BYTES) return null + file.readText(Charsets.UTF_8) + } + + fun delete(ids: Collection): Int = synchronized(lock) { + ids.mapNotNull(::resolveExisting).count(File::delete) + } + + /** Returns the entry file named by [id] without allowing traversal; it may not exist yet. */ + fun resolve(id: String): File? = + File(directory, id).takeIf { id.isNotBlank() && File(id).name == id } + + fun files(): List = directory.listFiles() + .orEmpty() + .filter { it.isFile && it.extension == ENTRY_EXTENSION } + + private fun resolveExisting(id: String): File? = resolve(id)?.takeIf { + it.isFile && it.parentFile?.canonicalFile == directory.canonicalFile + } + + fun toEntry(file: File) = CaptureHistoryStore.Entry( + id = file.name, + title = TITLE_TIME_FORMAT + .withZone(displayZone ?: ZoneId.systemDefault()) + .format(Instant.ofEpochMilli(file.lastModified())), + payloadType = file.name.split('_').getOrNull(1)?.toIntOrNull(), + ) + + companion object { + const val ENTRY_EXTENSION = "txt" + private val locks = ConcurrentHashMap() + private val TITLE_TIME_FORMAT = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") + } +} diff --git a/app/src/main/java/dev/gf2log/app/history/SavedHistoryStore.kt b/app/src/main/java/dev/gf2log/app/history/SavedHistoryStore.kt index defba1c..6083305 100644 --- a/app/src/main/java/dev/gf2log/app/history/SavedHistoryStore.kt +++ b/app/src/main/java/dev/gf2log/app/history/SavedHistoryStore.kt @@ -1,84 +1,59 @@ package dev.gf2log.app.history import java.io.File -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption +/** Explicitly saved history; capped at [MAX_ENTRIES] and never rotated. */ class SavedHistoryStore( - private val directory: File, + directory: File, ) { - private val entries = CaptureHistoryStore(directory) - - @Synchronized - fun saveFrom(source: CaptureHistoryStore, ids: Collection): SaveResult { - check(directory.isDirectory || directory.mkdirs()) { "Unable to create saved-history directory" } - - var saved = 0 - var alreadySaved = 0 - var missing = 0 - var limitReached = false - var storedCount = savedFiles().size - - ids.distinct().forEach { id -> - if (id.isBlank() || File(id).name != id) { - missing += 1 - return@forEach - } - - val destination = File(directory, id) - if (destination.isFile) { - alreadySaved += 1 - return@forEach - } - - val content = source.read(id) - if (content == null) { - missing += 1 - return@forEach - } - if (storedCount >= MAX_ENTRIES) { - limitReached = true - return@forEach + private val entries = HistoryDirectory(directory) + + fun saveFrom(source: CaptureHistoryStore, ids: Collection): SaveResult = + synchronized(entries.lock) { + entries.ensureExists("saved-history") + + var saved = 0 + var alreadySaved = 0 + var missing = 0 + var limitReached = false + var storedCount = entries.files().size + + ids.distinct().forEach { id -> + val destination = entries.resolve(id) + if (destination == null) { + missing += 1 + return@forEach + } + if (destination.isFile) { + alreadySaved += 1 + return@forEach + } + + val content = source.read(id) + if (content == null) { + missing += 1 + return@forEach + } + if (storedCount >= MAX_ENTRIES) { + limitReached = true + return@forEach + } + + AtomicFiles.write(destination, content.toByteArray(Charsets.UTF_8), "saved_packet_") + id.substringBefore('_').toLongOrNull()?.let(destination::setLastModified) + saved += 1 + storedCount += 1 } - writeAtomically(destination, content) - id.substringBefore('_').toLongOrNull()?.let(destination::setLastModified) - saved += 1 - storedCount += 1 + SaveResult(saved, alreadySaved, missing, limitReached) } - return SaveResult(saved, alreadySaved, missing, limitReached) - } - fun list(): List = entries.list() fun read(id: String): String? = entries.read(id) fun delete(ids: Collection): Int = entries.delete(ids) - private fun writeAtomically(destination: File, content: String) { - val temporary = File.createTempFile("saved_packet_", ".tmp", directory) - try { - temporary.writeText(content, Charsets.UTF_8) - try { - Files.move( - temporary.toPath(), - destination.toPath(), - StandardCopyOption.ATOMIC_MOVE, - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move(temporary.toPath(), destination.toPath()) - } - } finally { - temporary.delete() - } - } - - private fun savedFiles(): List = directory.listFiles() - .orEmpty() - .filter { it.isFile && it.extension == "txt" } - data class SaveResult( val saved: Int, val alreadySaved: Int, 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 2e1ea66..8bf4140 100644 --- a/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/history/CaptureHistoryStoreTest.kt @@ -75,6 +75,44 @@ class CaptureHistoryStoreTest { } } + @Test + fun listingNeverDeletesAndOnlySavingRotates() { + val directory = Files.createTempDirectory("gf2log-history-list-test").toFile() + try { + repeat(CaptureHistoryStore.MAX_ENTRIES + 5) { index -> + directory.resolve("${index + 1}_11138_$index.txt").apply { + writeText("entry $index") + setLastModified((index + 1) * 1_000L) + } + } + val store = CaptureHistoryStore(directory) + + assertEquals(CaptureHistoryStore.MAX_ENTRIES + 5, store.list().size) + assertEquals(CaptureHistoryStore.MAX_ENTRIES + 5, store.list().size) + + store.save(payload(1)) + assertEquals(CaptureHistoryStore.MAX_ENTRIES, store.list().size) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun storesForTheSameDirectoryShareOneLock() { + val directory = Files.createTempDirectory("gf2log-history-lock-test").toFile() + try { + assertTrue( + HistoryDirectory(directory).lock === + HistoryDirectory(directory.resolve("nested").resolve("..")).lock, + ) + assertFalse( + HistoryDirectory(directory).lock === HistoryDirectory(directory.resolve("other")).lock, + ) + } finally { + directory.deleteRecursively() + } + } + private fun payload(index: Int): ParsedPayload = ParsedPayload( messageId = index, payloadType = 11138, diff --git a/protocol/src/main/java/dev/gf2log/protocol/LogTime.kt b/protocol/src/main/java/dev/gf2log/protocol/LogTime.kt new file mode 100644 index 0000000..2012984 --- /dev/null +++ b/protocol/src/main/java/dev/gf2log/protocol/LogTime.kt @@ -0,0 +1,14 @@ +package dev.gf2log.protocol + +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** UTC second-precision timestamp shared by roster `logTime` cells and history envelopes. */ +object LogTime { + private val FORMAT: DateTimeFormatter = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + + fun format(instant: Instant): String = FORMAT.format(instant) +} From 70fc82e3fa2291cb766951bb4be0f81a3094c407 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 10:54:35 +0900 Subject: [PATCH 004/125] fix: write each completed roster CSV once with distinct row counts Keep the open batch in memory behind an atomically reserved .partial name and write the deduplicated roster once at protocol completion, so SaveResult.rowCount always matches the published file. --- .../app/capture/GuildMembersCsvWriter.kt | 161 +++++++----------- .../app/capture/GuildMembersCsvWriterTest.kt | 4 +- 2 files changed, 66 insertions(+), 99 deletions(-) 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 b5f2f5b..d2dd7bd 100644 --- a/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt +++ b/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt @@ -1,26 +1,26 @@ package dev.gf2log.app.capture +import dev.gf2log.app.history.AtomicFiles import dev.gf2log.protocol.Gfl2PayloadDecoder import dev.gf2log.protocol.GuildMembersCsv -import dev.gf2log.protocol.model.GuildMembersData +import dev.gf2log.protocol.LogTime import dev.gf2log.protocol.model.GuildMember +import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.ParsedPayload -import java.io.BufferedWriter import java.io.File -import java.io.FileOutputStream -import java.io.OutputStreamWriter -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption import java.time.Clock import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter +/** + * Collects one logical roster dataset in memory and writes it once, deduplicated by UID, only after + * protocol completion. An empty `.partial` file reserves the final name while the batch is open. + */ class GuildMembersCsvWriter( private val outputDirectory: File, private val clock: Clock = Clock.systemUTC(), - private val publisher: (File, File) -> Unit = ::publishCompletedFile, + private val publisher: (File, File) -> Unit = AtomicFiles::move, private val onBatchClosed: (CompletedBatch) -> Unit = {}, ) : AutoCloseable { private var activeBatch: Batch? = null @@ -28,111 +28,91 @@ class GuildMembersCsvWriter( @Synchronized fun accept(payload: ParsedPayload, flowEnded: Boolean = false): SaveResult? { if (payload.payloadType != Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) { - closeActiveBatch(completed = false) + discardActiveBatch() return null } val data = payload.data as? GuildMembersData ?: return null if (!GuildMembersCsv.hasValidMemberBounds(data.members)) { - closeActiveBatch(completed = false) + discardActiveBatch() error("Platoon roster exceeds the member or name limit") } var batch = activeBatch if (batch == null || (batch.previousMessageId != 0 && batch.previousMessageId != payload.messageId)) { - closeActiveBatch(completed = false) + discardActiveBatch() batch = openBatch() activeBatch = batch } - if ((batch.members.keys + data.members.map(GuildMember::uid)).distinct().size > + if ((batch.members.keys + data.members.map(GuildMember::uid)).size > GuildMembersCsv.MAX_ROSTER_MEMBERS ) { - closeActiveBatch(completed = false) + discardActiveBatch() error("Platoon roster exceeds the member limit") } - data.members.forEach { member -> - batch.writer.appendLine(GuildMembersCsv.row(member, batch.logTime)) - batch.members[member.uid] = member - batch.rows += 1 - } - batch.writer.flush() + data.members.forEach { member -> batch.members[member.uid] = member } batch.previousMessageId = payload.messageId - if (payload.messageId != 0 && payload.isEndOfMessage) { - return closeActiveBatch(completed = true)?.let { - SaveResult(it.file, batch.rows) - } - } else if (flowEnded) { - closeActiveBatch(completed = false) - } + if (payload.messageId != 0 && payload.isEndOfMessage) return publishActiveBatch() + if (flowEnded) discardActiveBatch() return null } @Synchronized - override fun close() { - closeActiveBatch(completed = false) - } + override fun close() = discardActiveBatch() private fun openBatch(): Batch { - outputDirectory.mkdirs() + check(outputDirectory.isDirectory || outputDirectory.mkdirs()) { + "Unable to create the roster directory" + } val instant = Instant.now(clock) val filenameStem = "gf2log_platoonmembers_${FILE_TIME_FORMAT.format(instant)}" - val finalFile = uniqueFile(filenameStem) - val workingFile = File(finalFile.parentFile, "${finalFile.name}.partial") - val writer = BufferedWriter( - OutputStreamWriter(FileOutputStream(workingFile), Charsets.UTF_8), - ) - writer.appendLine(GuildMembersCsv.HEADER) - writer.flush() - return Batch(workingFile, finalFile, LOG_TIME_FORMAT.format(instant), writer) - } - - private fun uniqueFile(filenameStem: String): File { var suffix = 1 - var candidate = File(outputDirectory, "$filenameStem.csv") - while (candidate.exists() || File(candidate.parentFile, "${candidate.name}.partial").exists()) { + while (true) { + val name = if (suffix == 1) "$filenameStem.csv" else "${filenameStem}_$suffix.csv" + val finalFile = File(outputDirectory, name) + val workingFile = File(outputDirectory, "$name.partial") + if (!finalFile.exists() && workingFile.createNewFile()) { + return Batch(workingFile, finalFile, LogTime.format(instant)) + } suffix += 1 - candidate = File(outputDirectory, "${filenameStem}_$suffix.csv") } - return candidate } - private fun closeActiveBatch(completed: Boolean): CompletedBatch? { + private fun discardActiveBatch() { + activeBatch?.workingFile?.delete() + activeBatch = null + } + + private fun publishActiveBatch(): SaveResult? { val batch = activeBatch ?: return null - batch.writer.close() - if (completed && batch.members.isNotEmpty()) { - val result = CompletedBatch( - file = batch.finalFile, - logTime = batch.logTime, - members = batch.members.values.toList(), - ) - try { - writeCanonicalRoster(batch) - publishWithRetry(batch.workingFile, batch.finalFile) - } catch (error: Exception) { - activeBatch = null - runCatching { onBatchClosed(result) } - .exceptionOrNull() - ?.let(error::addSuppressed) - throw error - } - activeBatch = null - onBatchClosed(result) - return result - } else { - activeBatch = null + activeBatch = null + if (batch.members.isEmpty()) { batch.workingFile.delete() + return null } - return null - } - - private fun writeCanonicalRoster(batch: Batch) { - batch.workingFile.bufferedWriter(Charsets.UTF_8).use { writer -> - writer.appendLine(GuildMembersCsv.HEADER) - batch.members.values.forEach { member -> - writer.appendLine(GuildMembersCsv.row(member, batch.logTime)) + val completed = CompletedBatch( + file = batch.finalFile, + logTime = batch.logTime, + members = batch.members.values.toList(), + ) + try { + batch.workingFile.bufferedWriter(Charsets.UTF_8).use { writer -> + writer.appendLine(GuildMembersCsv.HEADER) + completed.members.forEach { member -> + writer.appendLine(GuildMembersCsv.row(member, completed.logTime)) + } } + publishWithRetry(batch.workingFile, batch.finalFile) + } catch (error: Exception) { + // Structured ingestion still gets the completed batch; the caller sees the failure. + runCatching { onBatchClosed(completed) } + .exceptionOrNull() + ?.let(error::addSuppressed) + throw error } + onBatchClosed(completed) + return SaveResult(completed.file, completed.members.size) } private fun publishWithRetry(workingFile: File, finalFile: File) { @@ -153,6 +133,7 @@ class GuildMembersCsvWriter( } } + /** [rowCount] equals the number of distinct members written to [file]. */ data class SaveResult(val file: File, val rowCount: Int) data class CompletedBatch( @@ -161,36 +142,20 @@ class GuildMembersCsvWriter( val members: List, ) - private data class Batch( + private class Batch( val workingFile: File, val finalFile: File, val logTime: String, - val writer: BufferedWriter, - val members: LinkedHashMap = linkedMapOf(), - var previousMessageId: Int = -1, - var rows: Int = 0, - ) + ) { + val members = linkedMapOf() + var previousMessageId: Int = -1 + } companion object { private const val PUBLISH_ATTEMPTS = 2 - private fun publishCompletedFile(workingFile: File, finalFile: File) { - try { - Files.move( - workingFile.toPath(), - finalFile.toPath(), - StandardCopyOption.ATOMIC_MOVE, - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move(workingFile.toPath(), finalFile.toPath()) - } - } - - val FILE_TIME_FORMAT: DateTimeFormatter = DateTimeFormatter + private val FILE_TIME_FORMAT: DateTimeFormatter = DateTimeFormatter .ofPattern("yyyyMMdd'T'HHmmss'Z'") .withZone(ZoneOffset.UTC) - val LOG_TIME_FORMAT: DateTimeFormatter = DateTimeFormatter - .ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") - .withZone(ZoneOffset.UTC) } } 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 f3441bc..76c09da 100644 --- a/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt @@ -94,8 +94,10 @@ class GuildMembersCsvWriterTest { val writer = GuildMembersCsvWriter(output, clock, onBatchClosed = completed::add) writer.accept(payload(messageId = 0, uid = 1u, name = "Old", end = true)) - writer.accept(payload(messageId = 44, uid = 1u, name = "Current", end = true)) + val saved = writer.accept(payload(messageId = 44, uid = 1u, name = "Current", end = true)) + assertEquals(1, saved?.rowCount) + assertEquals(2, saved?.file?.readLines(Charsets.UTF_8)?.size) assertEquals(1, completed.size) assertEquals("2026-07-21T19:11:09Z", completed.single().logTime) assertEquals("Current", completed.single().members.single().name) From 2997761910230055b20c3a7fa67427abfbcfa6bf Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 10:55:59 +0900 Subject: [PATCH 005/125] refactor: share protocol CSV tokenizing and protobuf field loops Replace the two copies of the bounded CSV tokenizer and the history envelope split with single implementations, and collapse the repeated decoder field loops into inline helpers that keep zero-allocation skipping of unknown fields. --- .../gf2log/app/discord/OriginalCsvPayload.kt | 13 +- .../dev/gf2log/protocol/Gfl2PayloadDecoder.kt | 148 ++++-------------- .../dev/gf2log/protocol/GuildMembersCsv.kt | 57 +------ .../protocol/ParsedPacketTableParser.kt | 61 ++------ .../gf2log/protocol/internal/CsvRecords.kt | 66 ++++++++ .../gf2log/protocol/internal/ProtoReader.kt | 17 ++ .../dev/gf2log/protocol/CsvRecordsTest.kt | 24 +++ 7 files changed, 158 insertions(+), 228 deletions(-) create mode 100644 protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt create mode 100644 protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt diff --git a/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt b/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt index 4e3d21b..8b535c8 100644 --- a/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt +++ b/app/src/main/java/dev/gf2log/app/discord/OriginalCsvPayload.kt @@ -6,19 +6,16 @@ import dev.gf2log.protocol.ParsedPacketTableParser object OriginalCsvPayload { fun extract(historyContent: String): String { require(historyContent.length <= ParsedPacketTableParser.MAX_CONTENT_CHARS) - val normalized = historyContent.replace("\r\n", "\n").replace('\r', '\n') - val separator = normalized.indexOf("\n\n") - require(separator >= 0 && separator + 2 < normalized.length) { + val body = requireNotNull(ParsedPacketTableParser.csvBody(historyContent)) { "Parsed packet does not contain an original CSV body" } - requireNotNull(ParsedPacketTableParser.parse(normalized)) { + requireNotNull(ParsedPacketTableParser.parse(historyContent)) { "Parsed packet CSV failed bounded validation" } - return normalized.substring(separator + 2).also { - require(it.toByteArray(Charsets.UTF_8).size <= MAX_CSV_BYTES) { - "Original CSV exceeds the Discord send limit" - } + require(body.toByteArray(Charsets.UTF_8).size <= MAX_CSV_BYTES) { + "Original CSV exceeds the Discord send limit" } + return body } const val MAX_CSV_BYTES = 512 * 1024 diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt index 0003fa8..54c653e 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt @@ -1,6 +1,8 @@ package dev.gf2log.protocol import dev.gf2log.protocol.internal.ProtoReader +import dev.gf2log.protocol.internal.collectMessages +import dev.gf2log.protocol.internal.forEachField import dev.gf2log.protocol.model.Doll import dev.gf2log.protocol.model.Formation import dev.gf2log.protocol.model.FormationsData @@ -56,14 +58,8 @@ object Gfl2PayloadDecoder { else -> null } - private fun decodeWeapons(reader: ProtoReader): WeaponsData { - val weapons = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) weapons += decodeWeapon(reader.readMessage(field)) else reader.skip(field) - } - return WeaponsData(weapons) - } + private fun decodeWeapons(reader: ProtoReader) = + WeaponsData(reader.collectMessages(1, ::decodeWeapon)) private fun decodeWeapon(reader: ProtoReader): Weapon { var id = 0u @@ -74,8 +70,7 @@ object Gfl2PayloadDecoder { var breakTimes = 0u var rawFlags = 0u val weaponMods = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> id = reader.readUInt(field).toUInt() 2 -> stcId = reader.readUInt(field).toUInt() @@ -94,8 +89,7 @@ object Gfl2PayloadDecoder { private fun decodeWeaponModBinding(reader: ProtoReader): WeaponModBinding { var id = 0u var gunId = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> id = reader.readUInt(field).toUInt() 2 -> gunId = reader.readUInt(field).toUInt() @@ -105,18 +99,8 @@ object Gfl2PayloadDecoder { return WeaponModBinding(id, gunId) } - private fun decodeWeaponMods(reader: ProtoReader): WeaponModsData { - val mods = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) { - mods += decodeWeaponMod(reader.readMessage(field)) - } else { - reader.skip(field) - } - } - return WeaponModsData(mods) - } + private fun decodeWeaponMods(reader: ProtoReader) = + WeaponModsData(reader.collectMessages(1, ::decodeWeaponMod)) private fun decodeWeaponMod(reader: ProtoReader): WeaponMod { var id = 0u @@ -127,8 +111,7 @@ object Gfl2PayloadDecoder { var exp = 0u var suitFlags = 0uL - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> id = reader.readUInt(field).toUInt() 2 -> stcId = reader.readUInt(field).toUInt() @@ -152,18 +135,8 @@ object Gfl2PayloadDecoder { ) } - private fun decodePublicSkillItems(reader: ProtoReader): PublicSkillItemsData { - val items = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) { - items += decodePublicSkillItem(reader.readMessage(field)) - } else { - reader.skip(field) - } - } - return PublicSkillItemsData(items) - } + private fun decodePublicSkillItems(reader: ProtoReader) = + PublicSkillItemsData(reader.collectMessages(1, ::decodePublicSkillItem)) private fun decodePublicSkillItem(reader: ProtoReader): PublicSkillItem { var id = 0uL @@ -171,8 +144,7 @@ object Gfl2PayloadDecoder { var gunId = 0u var lockedFlags = 0uL var isNew = false - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> id = reader.readUInt(field) 2 -> stcId = reader.readUInt(field).toUInt() @@ -190,8 +162,7 @@ object Gfl2PayloadDecoder { var profile = PlatoonProfileData(0u, "", 0u, 0u) var questId = 0u var seasonId = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> profile = decodePlatoonProfileBody(reader.readMessage(field)) 2 -> questId = reader.readUInt(field).toUInt() @@ -212,8 +183,7 @@ object Gfl2PayloadDecoder { var bannerMarkId = 0u var declaration = "" var joinPolicyFlag = false - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> platoonId = reader.readUInt(field).toUInt() 2 -> platoonName = reader.readString(field).take(MAX_PLATOON_NAME_CHARS) @@ -242,18 +212,8 @@ object Gfl2PayloadDecoder { ) } - private fun decodeGuildMembers(reader: ProtoReader): GuildMembersData { - val members = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) { - members += decodeGuildMember(reader.readMessage(field)) - } else { - reader.skip(field) - } - } - return GuildMembersData(members) - } + private fun decodeGuildMembers(reader: ProtoReader) = + GuildMembersData(reader.collectMessages(1, ::decodeGuildMember)) private fun decodeGuildMember(reader: ProtoReader): GuildMember { var name = "" @@ -265,8 +225,7 @@ object Gfl2PayloadDecoder { var uid = 0u var lastLogin = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> { val player = decodePlayer(reader.readMessage(field)) @@ -289,8 +248,7 @@ object Gfl2PayloadDecoder { private fun decodePlatoonActivity(reader: ProtoReader): PlatoonActivityData { val summaries = mutableListOf() val entries = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> summaries += decodePlatoonActivitySummary(reader.readMessage(field)) 2 -> entries += decodePlatoonActivityEntry(reader.readMessage(field)) @@ -305,8 +263,7 @@ object Gfl2PayloadDecoder { var actionId = 0u var occurredAt = 0u var count = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> id = reader.readUInt(field) 2 -> actionId = reader.readUInt(field).toUInt() @@ -323,8 +280,7 @@ object Gfl2PayloadDecoder { var occurredAt = 0u var actionId = 0u var memberName = "" - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 2 -> kind = reader.readUInt(field).toUInt() 3 -> occurredAt = reader.readUInt(field).toUInt() @@ -336,25 +292,14 @@ object Gfl2PayloadDecoder { return PlatoonActivityEntry(kind, occurredAt, actionId, memberName) } - private fun decodePlatoonUpdates(reader: ProtoReader): PlatoonUpdatesData { - val entries = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) { - entries += decodePlatoonUpdateEntry(reader.readMessage(field)) - } else { - reader.skip(field) - } - } - return PlatoonUpdatesData(entries) - } + private fun decodePlatoonUpdates(reader: ProtoReader) = + PlatoonUpdatesData(reader.collectMessages(1, ::decodePlatoonUpdateEntry)) private fun decodePlatoonUpdateEntry(reader: ProtoReader): PlatoonUpdateEntry { var kind = 0u val members = mutableListOf() var occurredAt = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> kind = reader.readUInt(field).toUInt() 2 -> members += decodePlatoonUpdateMember(reader.readMessage(field)) @@ -369,8 +314,7 @@ object Gfl2PayloadDecoder { var role = 0u var uid = 0u var name = "" - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> role = reader.readUInt(field).toUInt() 2 -> uid = reader.readUInt(field).toUInt() @@ -381,20 +325,13 @@ object Gfl2PayloadDecoder { return PlatoonUpdateMember(role, uid, name) } - private fun decodePlayer(reader: ProtoReader): Pair { - var result = "" to 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) result = decodePlayerInfo(reader.readMessage(field)) else reader.skip(field) - } - return result - } + private fun decodePlayer(reader: ProtoReader): Pair = + reader.collectMessages(1, ::decodePlayerInfo).lastOrNull() ?: ("" to 0u) private fun decodePlayerInfo(reader: ProtoReader): Pair { var name = "" var level = 0u - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 2 -> name = reader.readString(field) 3 -> level = reader.readUInt(field).toUInt() @@ -404,33 +341,15 @@ object Gfl2PayloadDecoder { return name to level } - private fun decodeFormationsResponse(reader: ProtoReader): FormationsData { - val formations = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) { - formations += decodeFormationsContainer(reader.readMessage(field)) - } else { - reader.skip(field) - } - } - return FormationsData(formations) - } - - private fun decodeFormationsContainer(reader: ProtoReader): List { - val formations = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break - if (field.number == 1) formations += decodeFormation(reader.readMessage(field)) else reader.skip(field) - } - return formations - } + private fun decodeFormationsResponse(reader: ProtoReader) = FormationsData( + reader.collectMessages(1) { container -> container.collectMessages(1, ::decodeFormation) } + .flatten(), + ) private fun decodeFormation(reader: ProtoReader): Formation { var name = "" val dolls = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> name = reader.readString(field) 2 -> dolls += decodeDoll(reader.readMessage(field)) @@ -448,8 +367,7 @@ object Gfl2PayloadDecoder { val expansionKeyIds = mutableListOf() val commonKeyUids = mutableListOf() - while (!reader.exhausted) { - val field = reader.nextField() ?: break + reader.forEachField { field -> when (field.number) { 1 -> dollId = reader.readUInt(field).toUInt() 2 -> weaponUid = reader.readUInt(field) diff --git a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt index 491f0c6..6cf4c3e 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt @@ -1,5 +1,6 @@ package dev.gf2log.protocol +import dev.gf2log.protocol.internal.CsvRecords import dev.gf2log.protocol.model.GuildMember object GuildMembersCsv { @@ -64,7 +65,8 @@ 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 = parseRecords(content) ?: return null + val records = CsvRecords.parse(content, MAX_COLUMNS, MAX_RECORDS, MAX_FIELD_CHARS) + ?: return null val header = records.firstOrNull() ?: return null if (header != HEADER.split(',')) return null @@ -96,59 +98,6 @@ object GuildMembersCsv { return Snapshot(logTime = snapshotTime, members = members) } - private fun parseRecords(content: String): List>? { - val csv = content.replace("\r\n", "\n").replace('\r', '\n') - val records = mutableListOf>() - val row = mutableListOf() - val field = StringBuilder() - var quoted = false - var index = 0 - - while (index < csv.length) { - when (val character = csv[index]) { - '"' -> { - if (quoted && index + 1 < csv.length && csv[index + 1] == '"') { - field.append('"') - index += 1 - } else { - quoted = !quoted - } - } - ',' -> if (quoted) { - field.append(character) - } else { - if (row.size >= MAX_COLUMNS || field.length > MAX_FIELD_CHARS) return null - row += field.toString() - field.clear() - } - '\n' -> if (quoted) { - field.append(character) - } else { - if (row.size >= MAX_COLUMNS || field.length > MAX_FIELD_CHARS) return null - row += field.toString() - field.clear() - if (records.size >= MAX_RECORDS) return null - records += row.toList() - row.clear() - } - else -> { - if (field.length >= MAX_FIELD_CHARS) return null - field.append(character) - } - } - index += 1 - } - - if (quoted) return null - if (field.isNotEmpty() || row.isNotEmpty()) { - if (row.size >= MAX_COLUMNS || field.length > MAX_FIELD_CHARS) return null - row += field.toString() - if (records.size >= MAX_RECORDS) return null - records += row.toList() - } - return records - } - // Function Name: parseOptionalCounter // Description: // - Matches protobuf scalar semantics by treating an omitted counter as zero. diff --git a/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt b/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt index 7d97e58..5b53c62 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/ParsedPacketTableParser.kt @@ -1,13 +1,12 @@ package dev.gf2log.protocol +import dev.gf2log.protocol.internal.CsvRecords + object ParsedPacketTableParser { fun parse(content: String): Table? { if (content.length > MAX_CONTENT_CHARS) return null - val normalized = content.replace("\r\n", "\n").replace('\r', '\n') - val separator = normalized.indexOf("\n\n") - if (separator < 0 || separator + 2 >= normalized.length) return null - - val records = parseCsv(normalized.substring(separator + 2)) ?: return null + val body = csvBody(content) ?: return null + val records = CsvRecords.parse(body, MAX_COLUMNS, MAX_RECORDS, MAX_CELL_CHARS) ?: return null val header = records.firstOrNull() ?.takeIf { it.isNotEmpty() && it.size <= MAX_COLUMNS } ?: return null @@ -22,52 +21,12 @@ object ParsedPacketTableParser { ) } - private fun parseCsv(csv: String): List>? { - val records = mutableListOf>() - val row = mutableListOf() - val field = StringBuilder() - var quoted = false - var index = 0 - - while (index < csv.length) { - when (val character = csv[index]) { - '"' -> { - if (quoted && index + 1 < csv.length && csv[index + 1] == '"') { - field.append('"') - index += 1 - } else { - quoted = !quoted - } - } - ',' -> if (quoted) field.append(character) else { - if (row.size >= MAX_COLUMNS || field.length > MAX_CELL_CHARS) return null - row += field.toString() - field.clear() - } - '\n' -> if (quoted) field.append(character) else { - if (row.size >= MAX_COLUMNS || field.length > MAX_CELL_CHARS) return null - row += field.toString() - field.clear() - if (records.size >= MAX_RECORDS) return null - records += row.toList() - row.clear() - } - else -> { - if (field.length >= MAX_CELL_CHARS) return null - field.append(character) - } - } - index += 1 - } - - if (quoted) return null - if (field.isNotEmpty() || row.isNotEmpty()) { - if (row.size >= MAX_COLUMNS || field.length > MAX_CELL_CHARS) return null - row += field.toString() - if (records.size >= MAX_RECORDS) return null - records += row.toList() - } - return records + /** Returns the line-ending-normalized CSV after a history envelope's blank separator line. */ + 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) } data class Table( diff --git a/protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt b/protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt new file mode 100644 index 0000000..758b11a --- /dev/null +++ b/protocol/src/main/java/dev/gf2log/protocol/internal/CsvRecords.kt @@ -0,0 +1,66 @@ +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. */ + fun parse( + content: String, + maxColumns: Int, + maxRecords: Int, + maxFieldChars: Int, + ): List>? { + val csv = normalizeLineEndings(content) + val records = mutableListOf>() + val row = mutableListOf() + val field = StringBuilder() + var quoted = false + var index = 0 + + fun endField(): Boolean { + if (row.size >= maxColumns || field.length > maxFieldChars) return false + row += field.toString() + field.clear() + return true + } + + fun endRecord(): Boolean { + if (records.size >= maxRecords) return false + records += row.toList() + row.clear() + return true + } + + while (index < csv.length) { + when (val character = csv[index]) { + '"' -> { + if (quoted && index + 1 < csv.length && csv[index + 1] == '"') { + field.append('"') + index += 1 + } else { + quoted = !quoted + } + } + ',' -> if (quoted) field.append(character) else if (!endField()) return null + '\n' -> if (quoted) { + field.append(character) + } else if (!endField() || !endRecord()) { + return null + } + else -> { + if (field.length >= maxFieldChars) return null + field.append(character) + } + } + index += 1 + } + + if (quoted) return null + if (field.isNotEmpty() || row.isNotEmpty()) { + if (!endField() || !endRecord()) return null + } + return records + } + + fun normalizeLineEndings(content: String): String = + content.replace("\r\n", "\n").replace('\r', '\n') +} 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 8b55690..f26fd43 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/internal/ProtoReader.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/internal/ProtoReader.kt @@ -109,3 +109,20 @@ internal class ProtoReader( } } } + +/** Visits every field until the message is exhausted; handlers must read or [ProtoReader.skip] it. */ +internal inline fun ProtoReader.forEachField(action: (ProtoField) -> Unit) { + while (true) action(nextField() ?: return) +} + +/** Decodes every [fieldNumber] submessage and skips all other fields. */ +internal inline fun ProtoReader.collectMessages( + fieldNumber: Int, + decode: (ProtoReader) -> T, +): List { + val items = mutableListOf() + forEachField { field -> + if (field.number == fieldNumber) items += decode(readMessage(field)) else skip(field) + } + return items +} diff --git a/protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt b/protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt new file mode 100644 index 0000000..4553b0d --- /dev/null +++ b/protocol/src/test/java/dev/gf2log/protocol/CsvRecordsTest.kt @@ -0,0 +1,24 @@ +package dev.gf2log.protocol + +import dev.gf2log.protocol.internal.CsvRecords +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CsvRecordsTest { + @Test + fun quotedDelimitersLineBreaksAndEscapedQuotesStayInOneField() { + assertEquals( + listOf(listOf("a,b", "line\nbreak", "say \"hi\""), listOf("2", "", "")), + CsvRecords.parse("\"a,b\",\"line\r\nbreak\",\"say \"\"hi\"\"\"\r\n2,,", 3, 2, 32), + ) + } + + @Test + fun everyBoundAndUnbalancedQuoteRejectsTheDocument() { + assertNull(CsvRecords.parse("a,b,c", maxColumns = 2, maxRecords = 1, maxFieldChars = 8)) + assertNull(CsvRecords.parse("a\nb\nc", maxColumns = 1, maxRecords = 2, maxFieldChars = 8)) + assertNull(CsvRecords.parse("abcdef", maxColumns = 1, maxRecords = 1, maxFieldChars = 5)) + assertNull(CsvRecords.parse("\"open", maxColumns = 1, maxRecords = 1, maxFieldChars = 8)) + } +} From 7e4a19571a19b1e3455b9352b23be55659f54e6c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 10:56:29 +0900 Subject: [PATCH 006/125] fix: recover CSV checkpoint restores into scoped settings --- .../PlatoonBackupManagerIntegrationTest.kt | 19 +++++++ .../management/CsvImportCheckpointManager.kt | 53 ++++++++----------- 2 files changed, 42 insertions(+), 30 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 7d604b0..c25dcc7 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -479,6 +479,25 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(CsvImportCheckpointManager(context, storageScope).canUndo()) } + @Test + fun csvCheckpointRecoversAnInterruptedRestoreIntoTheScopedSettings() { + settingsStore.replace(currentSettings()) + FilePaths.restoreSettingsFile(context).apply { + parentFile?.mkdirs() + writeBytes(AppBackupSettingsCodec.encode(archivedSettings())) + } + FilePaths.restoreStateFile(context).writeText("PREPARED", Charsets.US_ASCII) + + CsvImportCheckpointManager(context, storageScope).apply { + create(emptySet()) + seal() + } + + assertEquals(archivedSettings(), settingsStore.read()) + assertTrue(dev.gf2log.app.settings.UserSettingsPreferences.memberOrder(context).isEmpty()) + assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) + } + @Test fun interruptedCsvUndoCompletesAfterDatabaseInstallationOnRestart() { seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt index 6d94684..e50c7e1 100644 --- a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt @@ -1,7 +1,7 @@ package dev.gf2log.app.management import android.content.Context -import dev.gf2log.app.settings.AppSettingsStore +import dev.gf2log.app.settings.ScopedAppSettingsStore import java.io.File import java.io.FileOutputStream @@ -68,11 +68,7 @@ class CsvImportCheckpointManager internal constructor( deleteDirectory(staging) check(staging.mkdirs()) { "Unable to stage the CSV import checkpoint" } FileOutputStream(archive(staging)).use { output -> - PlatoonBackupManager( - appContext, - AppSettingsStore(appContext), - storageScope = storageScope, - ).export(output) + backupManager().export(output) output.fd.sync() } FileOutputStream(manifest(staging)).use { output -> @@ -108,12 +104,7 @@ class CsvImportCheckpointManager internal constructor( try { FileOutputStream(temporary).use { output -> output.write( - PlatoonBackupManager( - appContext, - AppSettingsStore(appContext), - storageScope = storageScope, - ).currentDatabaseSha256() - .toByteArray(Charsets.US_ASCII), + backupManager().currentDatabaseSha256().toByteArray(Charsets.US_ASCII), ) output.fd.sync() } @@ -141,13 +132,7 @@ class CsvImportCheckpointManager internal constructor( } val expectedDigest = digest(root).readText(Charsets.US_ASCII) require(expectedDigest.matches(SHA256)) { "Invalid CSV checkpoint digest" } - check( - PlatoonBackupManager( - appContext, - AppSettingsStore(appContext), - storageScope = storageScope, - ).currentDatabaseSha256() == expectedDigest, - ) { + check(backupManager().currentDatabaseSha256() == expectedDigest) { "Platoon data changed after the CSV import; undo would overwrite newer changes" } restoreUnchecked() @@ -188,17 +173,12 @@ class CsvImportCheckpointManager internal constructor( check(source.renameTo(target)) { "Unable to quarantine an imported CSV" } } } - val backupManager = PlatoonBackupManager( - context = appContext, - settingsStore = AppSettingsStore(appContext), - storageScope = storageScope, - restoreObserver = { checkpoint -> - if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { - markRestoreDatabaseInstalled() - } - restoreObserver(checkpoint) - }, - ) + val backupManager = backupManager { checkpoint -> + if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { + markRestoreDatabaseInstalled() + } + restoreObserver(checkpoint) + } archive(root).inputStream().use(backupManager::restoreCheckpoint) } catch (error: Exception) { val marker = restoreMarker(root) @@ -297,6 +277,19 @@ class CsvImportCheckpointManager internal constructor( } } + /** + * Always binds restore recovery to this scope's settings. The global store would roll an + * interrupted scoped restore back into the legacy keys instead of the Platoon's own settings. + */ + private fun backupManager( + observer: (PlatoonBackupManager.RestoreCheckpoint) -> Unit = {}, + ) = PlatoonBackupManager( + context = appContext, + settingsStore = ScopedAppSettingsStore(appContext, storageScope.storageId), + restoreObserver = observer, + storageScope = storageScope, + ) + private fun requireSafeImportName(name: String) { require(name.matches(SAFE_IMPORT_NAME)) { "Invalid CSV checkpoint identity" } } From c8bcd8a5555e5d114f0be767604a294f22c4f271 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 10:56:47 +0900 Subject: [PATCH 007/125] refactor: add edge-to-edge activity base and shared UI components Move every screen onto ComponentActivity with system-bar and keyboard insets, and add the shared top bar, screen shell, background work, document access, label, dialog, and state-view building blocks used by the redesigned screens. --- app/build.gradle | 1 + .../main/java/dev/gf2log/app/BaseActivity.kt | 75 ++++ .../java/dev/gf2log/app/LocalizedActivity.kt | 47 -- .../main/java/dev/gf2log/app/MainActivity.kt | 13 +- .../dev/gf2log/app/MemberDetailActivity.kt | 2 +- .../dev/gf2log/app/MemberOrderActivity.kt | 2 +- app/src/main/java/dev/gf2log/app/ModernUi.kt | 28 +- .../java/dev/gf2log/app/OnboardingActivity.kt | 2 +- .../java/dev/gf2log/app/OptionsActivity.kt | 9 +- .../dev/gf2log/app/PacketHistoryActivity.kt | 5 +- .../java/dev/gf2log/app/PlatoonActivity.kt | 7 +- .../java/dev/gf2log/app/PrimaryNavigation.kt | 7 +- .../gf2log/app/SnapshotComparisonActivity.kt | 2 +- .../gf2log/app/TrustedExportDestination.java | 51 --- .../dev/gf2log/app/TrustedImportSource.java | 37 -- .../dev/gf2log/app/WeeklyReportActivity.kt | 9 +- .../dev/gf2log/app/WeeklySettingsActivity.kt | 2 +- .../gf2log/app/WeeklyTableHistoryActivity.kt | 2 +- .../app/WeeklyTableHistoryPreviewActivity.kt | 2 +- .../java/dev/gf2log/app/ui/BackgroundWork.kt | 70 +++ .../main/java/dev/gf2log/app/ui/Dialogs.kt | 125 ++++++ .../java/dev/gf2log/app/ui/DocumentAccess.kt | 53 +++ app/src/main/java/dev/gf2log/app/ui/Labels.kt | 91 ++++ app/src/main/java/dev/gf2log/app/ui/Screen.kt | 94 ++++ .../main/java/dev/gf2log/app/ui/StateViews.kt | 64 +++ app/src/main/java/dev/gf2log/app/ui/TopBar.kt | 118 +++++ app/src/main/java/dev/gf2log/app/ui/Views.kt | 415 ++++++++++++++++++ app/src/main/res/drawable/ic_arrow_down.xml | 9 + app/src/main/res/drawable/ic_arrow_up.xml | 9 + app/src/main/res/drawable/ic_bolt.xml | 9 + app/src/main/res/drawable/ic_bug.xml | 9 + app/src/main/res/drawable/ic_check.xml | 9 + app/src/main/res/drawable/ic_check_circle.xml | 9 + app/src/main/res/drawable/ic_chevron_left.xml | 14 + app/src/main/res/drawable/ic_chevron_next.xml | 14 + .../main/res/drawable/ic_circle_outline.xml | 9 + app/src/main/res/drawable/ic_close.xml | 9 + app/src/main/res/drawable/ic_code.xml | 9 + app/src/main/res/drawable/ic_compare.xml | 9 + app/src/main/res/drawable/ic_copy.xml | 9 + app/src/main/res/drawable/ic_download.xml | 9 + app/src/main/res/drawable/ic_expand_more.xml | 9 + app/src/main/res/drawable/ic_more_vert.xml | 9 + app/src/main/res/drawable/ic_notes.xml | 9 + app/src/main/res/drawable/ic_palette.xml | 9 + app/src/main/res/drawable/ic_person.xml | 9 + app/src/main/res/drawable/ic_person_add.xml | 9 + app/src/main/res/drawable/ic_play.xml | 9 + app/src/main/res/drawable/ic_search.xml | 9 + app/src/main/res/drawable/ic_shield.xml | 9 + app/src/main/res/drawable/ic_sort.xml | 9 + app/src/main/res/drawable/ic_stop.xml | 9 + app/src/main/res/drawable/ic_storage.xml | 9 + app/src/main/res/drawable/ic_table.xml | 9 + app/src/main/res/drawable/ic_today.xml | 9 + app/src/main/res/drawable/ic_translate.xml | 9 + app/src/main/res/drawable/ic_tune.xml | 9 + app/src/main/res/drawable/ic_undo.xml | 9 + app/src/main/res/drawable/ic_upload.xml | 9 + app/src/main/res/drawable/ic_warning.xml | 9 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values/ids.xml | 8 + app/src/main/res/values/strings.xml | 3 + 63 files changed, 1486 insertions(+), 168 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/BaseActivity.kt delete mode 100644 app/src/main/java/dev/gf2log/app/LocalizedActivity.kt delete mode 100644 app/src/main/java/dev/gf2log/app/TrustedExportDestination.java delete mode 100644 app/src/main/java/dev/gf2log/app/TrustedImportSource.java create mode 100644 app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt create mode 100644 app/src/main/java/dev/gf2log/app/ui/Dialogs.kt create mode 100644 app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt create mode 100644 app/src/main/java/dev/gf2log/app/ui/Labels.kt create mode 100644 app/src/main/java/dev/gf2log/app/ui/Screen.kt create mode 100644 app/src/main/java/dev/gf2log/app/ui/StateViews.kt create mode 100644 app/src/main/java/dev/gf2log/app/ui/TopBar.kt create mode 100644 app/src/main/java/dev/gf2log/app/ui/Views.kt create mode 100644 app/src/main/res/drawable/ic_arrow_down.xml create mode 100644 app/src/main/res/drawable/ic_arrow_up.xml create mode 100644 app/src/main/res/drawable/ic_bolt.xml create mode 100644 app/src/main/res/drawable/ic_bug.xml create mode 100644 app/src/main/res/drawable/ic_check.xml create mode 100644 app/src/main/res/drawable/ic_check_circle.xml create mode 100644 app/src/main/res/drawable/ic_chevron_left.xml create mode 100644 app/src/main/res/drawable/ic_chevron_next.xml create mode 100644 app/src/main/res/drawable/ic_circle_outline.xml create mode 100644 app/src/main/res/drawable/ic_close.xml create mode 100644 app/src/main/res/drawable/ic_code.xml create mode 100644 app/src/main/res/drawable/ic_compare.xml create mode 100644 app/src/main/res/drawable/ic_copy.xml create mode 100644 app/src/main/res/drawable/ic_download.xml create mode 100644 app/src/main/res/drawable/ic_expand_more.xml create mode 100644 app/src/main/res/drawable/ic_more_vert.xml create mode 100644 app/src/main/res/drawable/ic_notes.xml create mode 100644 app/src/main/res/drawable/ic_palette.xml create mode 100644 app/src/main/res/drawable/ic_person.xml create mode 100644 app/src/main/res/drawable/ic_person_add.xml create mode 100644 app/src/main/res/drawable/ic_play.xml create mode 100644 app/src/main/res/drawable/ic_search.xml create mode 100644 app/src/main/res/drawable/ic_shield.xml create mode 100644 app/src/main/res/drawable/ic_sort.xml create mode 100644 app/src/main/res/drawable/ic_stop.xml create mode 100644 app/src/main/res/drawable/ic_storage.xml create mode 100644 app/src/main/res/drawable/ic_table.xml create mode 100644 app/src/main/res/drawable/ic_today.xml create mode 100644 app/src/main/res/drawable/ic_translate.xml create mode 100644 app/src/main/res/drawable/ic_tune.xml create mode 100644 app/src/main/res/drawable/ic_undo.xml create mode 100644 app/src/main/res/drawable/ic_upload.xml create mode 100644 app/src/main/res/drawable/ic_warning.xml diff --git a/app/build.gradle b/app/build.gradle index dde734e..bfcdab5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -223,6 +223,7 @@ android { dependencies { implementation project(':protocol') + implementation 'androidx.activity:activity:1.12.4' implementation 'androidx.core:core:1.17.0' implementation 'androidx.recyclerview:recyclerview:1.4.0' testImplementation 'junit:junit:4.13.2' diff --git a/app/src/main/java/dev/gf2log/app/BaseActivity.kt b/app/src/main/java/dev/gf2log/app/BaseActivity.kt new file mode 100644 index 0000000..53083f8 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/BaseActivity.kt @@ -0,0 +1,75 @@ +package dev.gf2log.app + +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge + +/** + * Applies the persisted language and appearance before views exist, draws edge to edge, and + * hosts the pending-Platoon admission prompt for every screen that supports it. + */ +abstract class BaseActivity : ComponentActivity() { + private var attachedLanguage = LanguagePreferences.DEFAULT_LANGUAGE + private var attachedTheme = ThemePreferences.SYSTEM + + override fun attachBaseContext(newBase: Context) { + attachedLanguage = LanguagePreferences.get(newBase) + attachedTheme = preferredTheme(newBase) + val localized = LanguagePreferences.wrap(newBase, attachedLanguage) + super.attachBaseContext(ThemePreferences.wrap(localized, attachedTheme)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + // The default detector reads this Activity's resources, which already carry the app's + // own light/dark choice rather than the system setting. + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), + ) + super.onCreate(savedInstanceState) + } + + /** Allows a purpose-built surface to use a stable presentation theme. */ + protected open fun preferredTheme(context: Context): String = ThemePreferences.get(context) + + protected open fun supportsPendingPlatoonAdmission(): Boolean = true + + final override fun onResume() { + super.onResume() + if ( + LanguagePreferences.get(this) != attachedLanguage || + preferredTheme(this) != attachedTheme + ) { + recreate() + return + } + onScreenResumed() + if (supportsPendingPlatoonAdmission() && OnboardingPreferences.isCompleted(this)) { + PendingPlatoonAdmissionPrompt.showIfNeeded(this) + } + } + + /** Called from onResume only when this instance remains current. */ + protected open fun onScreenResumed() = Unit + + /** Screens not yet composed from shared components keep the legacy tree styling pass. */ + protected open val legacyStyling: Boolean = true + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + if (!legacyStyling) return + val content = findViewById(android.R.id.content) + androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(content) { view, insets -> + val bars = insets.getInsets( + androidx.core.view.WindowInsetsCompat.Type.systemBars() or + androidx.core.view.WindowInsetsCompat.Type.ime(), + ) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + androidx.core.view.WindowInsetsCompat.CONSUMED + } + ModernUi.prepareContent(window.decorView) + window.decorView.post { if (!isFinishing) ModernUi.prepareContent(window.decorView) } + } +} diff --git a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt b/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt deleted file mode 100644 index f9e3c4e..0000000 --- a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt +++ /dev/null @@ -1,47 +0,0 @@ -package dev.gf2log.app - -import android.app.Activity -import android.content.Context -import android.os.Bundle - -abstract class LocalizedActivity : Activity() { - private var attachedLanguage = LanguagePreferences.DEFAULT_LANGUAGE - private var attachedTheme = ThemePreferences.SYSTEM - - override fun attachBaseContext(newBase: Context) { - attachedLanguage = LanguagePreferences.get(newBase) - attachedTheme = preferredTheme(newBase) - val localized = LanguagePreferences.wrap(newBase, attachedLanguage) - super.attachBaseContext(ThemePreferences.wrap(localized, attachedTheme)) - } - - /** Allows a purpose-built surface to use a stable presentation theme. */ - protected open fun preferredTheme(context: Context): String = ThemePreferences.get(context) - - protected open fun supportsPendingPlatoonAdmission(): Boolean = true - - override fun onResume() { - super.onResume() - if ( - LanguagePreferences.get(this) != attachedLanguage || - preferredTheme(this) != attachedTheme - ) { - recreate() - return - } - if (supportsPendingPlatoonAdmission() && OnboardingPreferences.isCompleted(this)) { - PendingPlatoonAdmissionPrompt.showIfNeeded(this) - } - } - - override fun onPostCreate(savedInstanceState: Bundle?) { - super.onPostCreate(savedInstanceState) - ModernUi.prepareContent(window.decorView) - // Android restores parts of the view hierarchy after onPostCreate during - // locale/theme recreation. Reapply presentation roles on the next frame so - // restored platform drawables cannot replace the app's semantic controls. - window.decorView.post { - if (!isFinishing) ModernUi.prepareContent(window.decorView) - } - } -} diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 817b2e3..d3ef969 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -49,7 +49,7 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.concurrent.Executors -class MainActivity : LocalizedActivity() { +class MainActivity : BaseActivity() { private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var statusText: TextView private lateinit var captureStateText: TextView @@ -111,8 +111,7 @@ class MainActivity : LocalizedActivity() { super.onSaveInstanceState(outState) } - override fun onResume() { - super.onResume() + override fun onScreenResumed() { if (!profileBinding.isCurrent(this)) { recreate() return @@ -147,7 +146,7 @@ class MainActivity : LocalizedActivity() { successMessage = { getString(R.string.status_exported, source.name) }, failureMessage = { getString(R.string.status_export_failed) }, ) { - val output = TrustedExportDestination.openOutputStream( + val output = dev.gf2log.app.ui.DocumentAccess.openOutput( contentResolver, destination, ) @@ -168,7 +167,7 @@ class MainActivity : LocalizedActivity() { successMessage = { getString(R.string.status_backup_exported) }, failureMessage = { getString(R.string.status_backup_failed) }, ) { - val output = TrustedExportDestination.openOutputStream( + val output = dev.gf2log.app.ui.DocumentAccess.openOutput( contentResolver, destination, ) ?: error("Document provider did not open an output stream") @@ -187,7 +186,7 @@ class MainActivity : LocalizedActivity() { failureMessage = { getString(R.string.status_backup_failed) }, onSuccess = { recreate() }, ) { - val input = TrustedImportSource.openInputStream(contentResolver, source) + val input = dev.gf2log.app.ui.DocumentAccess.openInput(contentResolver, source) ?: error("Document provider did not open an input stream") input.use { PlatoonBackupManager.restoreSelected(this, it, complete = false) } } @@ -738,7 +737,7 @@ class MainActivity : LocalizedActivity() { val selected = ArrayList(sources.size) var selectedBytes = 0L sources.forEach { source -> - val input = TrustedImportSource.openInputStream(contentResolver, source) + val input = dev.gf2log.app.ui.DocumentAccess.openInput(contentResolver, source) ?: error("Document provider did not open an input stream") val prepared = input.use(store::prepare) selectedBytes += prepared.byteCount diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 593a39c..f9c9db7 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -19,7 +19,7 @@ import dev.gf2log.app.settings.GameTimeZonePreferences import java.time.Instant import java.time.format.DateTimeFormatter -class MemberDetailActivity : LocalizedActivity() { +class MemberDetailActivity : BaseActivity() { private lateinit var repository: PlatoonRepository private var uid: Long = -1 diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index b6305e3..6e5c3bd 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -25,7 +25,7 @@ import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.settings.MemberOrderPreferences -class MemberOrderActivity : LocalizedActivity() { +class MemberOrderActivity : BaseActivity() { private lateinit var listContainer: LinearLayout private lateinit var scrollView: ScrollView private lateinit var preferences: MemberOrderPreferences diff --git a/app/src/main/java/dev/gf2log/app/ModernUi.kt b/app/src/main/java/dev/gf2log/app/ModernUi.kt index ccd95e9..e795334 100644 --- a/app/src/main/java/dev/gf2log/app/ModernUi.kt +++ b/app/src/main/java/dev/gf2log/app/ModernUi.kt @@ -565,7 +565,7 @@ object ModernUi { ), ) - private fun controlTint(context: Context) = ColorStateList( + internal fun controlTint(context: Context) = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_enabled), intArrayOf(android.R.attr.state_checked), @@ -578,6 +578,32 @@ object ModernUi { ), ) + internal fun switchThumbTint(context: Context) = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + intArrayOf( + context.getColor(R.color.surface_variant), + context.getColor(R.color.accent), + context.getColor(R.color.surface), + ), + ) + + internal fun switchTrackTint(context: Context) = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + intArrayOf( + context.getColor(R.color.outline), + context.getColor(R.color.accent_surface_pressed), + context.getColor(R.color.outline_strong), + ), + ) + private data class ButtonColors( val normal: Int, val pressed: Int, diff --git a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt index ccf0aa0..441d69e 100644 --- a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt @@ -21,7 +21,7 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes /** One-time, bilingual walkthrough of the app's five user-facing workflows. */ -class OnboardingActivity : LocalizedActivity() { +class OnboardingActivity : BaseActivity() { override fun supportsPendingPlatoonAdmission(): Boolean = false private lateinit var pageHost: FrameLayout diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 47cdb4c..1640306 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -45,7 +45,7 @@ import java.time.LocalDateTime import java.util.concurrent.Executors import dev.gf2log.protocol.PayloadCatalog -class OptionsActivity : LocalizedActivity() { +class OptionsActivity : BaseActivity() { private lateinit var profileBinding: ActivePlatoonScopeBinding private val mainHandler = Handler(Looper.getMainLooper()) private val fileIoExecutor = Executors.newSingleThreadExecutor { runnable -> @@ -59,8 +59,7 @@ class OptionsActivity : LocalizedActivity() { setContentView(buildContentView()) } - override fun onResume() { - super.onResume() + override fun onScreenResumed() { if (!profileBinding.isCurrent(this)) { recreate() return @@ -84,7 +83,7 @@ class OptionsActivity : LocalizedActivity() { successMessage = R.string.full_backup_exported, failureMessage = { R.string.full_backup_export_failed }, ) { - val output = TrustedExportDestination.openOutputStream(contentResolver, uri) + val output = dev.gf2log.app.ui.DocumentAccess.openOutput(contentResolver, uri) ?: error("Document provider did not open an output stream") output.use { PlatoonBackupManager(this, scope).exportFull(it) } } @@ -98,7 +97,7 @@ class OptionsActivity : LocalizedActivity() { successMessage = R.string.full_backup_restored, failureMessage = ::fullBackupRestoreFailureMessage, ) { - val input = TrustedImportSource.openInputStream(contentResolver, uri) + val input = dev.gf2log.app.ui.DocumentAccess.openInput(contentResolver, uri) ?: error("Document provider did not open an input stream") input.use { PlatoonBackupManager.restoreSelected(this, it, complete = true) } } diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index 799e27b..0f73e07 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -31,7 +31,7 @@ import java.io.File import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonStorageScope -class PacketHistoryActivity : LocalizedActivity() { +class PacketHistoryActivity : BaseActivity() { private lateinit var actionButton: Button private lateinit var contentContainer: LinearLayout private lateinit var sendButton: Button @@ -143,8 +143,7 @@ class PacketHistoryActivity : LocalizedActivity() { } - override fun onResume() { - super.onResume() + override fun onScreenResumed() { if (::sendButton.isInitialized) refreshSendAvailability() } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index b088957..6b80e9a 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -27,7 +27,7 @@ import dev.gf2log.app.settings.GameTimeZonePreferences import java.time.format.DateTimeFormatter import java.util.concurrent.Executors -class PlatoonActivity : LocalizedActivity() { +class PlatoonActivity : BaseActivity() { private lateinit var repository: PlatoonRepository private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var summary: TextView @@ -63,8 +63,7 @@ class PlatoonActivity : LocalizedActivity() { ) } - override fun onResume() { - super.onResume() + override fun onScreenResumed() { if (!profileBinding.isCurrent(this)) { recreate() return @@ -323,7 +322,7 @@ class PlatoonActivity : LocalizedActivity() { val destination = data?.data if (resultCode != RESULT_OK || destination == null || content == null) return val exported = runCatching { - val output = TrustedExportDestination.openOutputStream(contentResolver, destination) + val output = dev.gf2log.app.ui.DocumentAccess.openOutput(contentResolver, destination) ?: error("Document provider did not open an output stream") output.writer(Charsets.UTF_8).use { it.write(content) } }.isSuccess diff --git a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt index 3a034ff..102d96d 100644 --- a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt +++ b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt @@ -42,7 +42,12 @@ internal object PrimaryNavigation { )) } - private fun navigationBar(activity: Activity, selected: Destination): View = + /** The bottom bar alone, for screens composed through [dev.gf2log.app.ui.Screen]. */ + fun bar(activity: Activity, selected: Destination): View = navigationBar(activity, selected).apply { + minimumHeight = activity.dp(64) + } + + private fun navigationBar(activity: Activity, selected: Destination): LinearLayout = LinearLayout(activity).apply { orientation = LinearLayout.HORIZONTAL setBackgroundColor(activity.getColor(R.color.surface)) diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index 69451bc..c153824 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -16,7 +16,7 @@ import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.settings.GameTimeZonePreferences import java.time.format.DateTimeFormatter -class SnapshotComparisonActivity : LocalizedActivity() { +class SnapshotComparisonActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val snapshots = PlatoonRepository(this).listSnapshots(2) diff --git a/app/src/main/java/dev/gf2log/app/TrustedExportDestination.java b/app/src/main/java/dev/gf2log/app/TrustedExportDestination.java deleted file mode 100644 index 2a14002..0000000 --- a/app/src/main/java/dev/gf2log/app/TrustedExportDestination.java +++ /dev/null @@ -1,51 +0,0 @@ -package dev.gf2log.app; - -import android.content.ContentResolver; -import android.net.Uri; - -import java.io.FileNotFoundException; -import java.io.OutputStream; -import java.nio.file.FileSystems; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -final class TrustedExportDestination { - private static final Set TRUSTED_DOCUMENT_AUTHORITIES = new HashSet<>( - Arrays.asList( - "com.android.externalstorage.documents", - "com.android.providers.downloads.documents", - "com.android.providers.media.documents", - "com.google.android.apps.docs.storage", - "com.google.android.apps.nbu.files.provider" - ) - ); - - private TrustedExportDestination() { - } - - static OutputStream openOutputStream(ContentResolver resolver, Uri destination) - throws FileNotFoundException { - if (!ContentResolver.SCHEME_CONTENT.equals(destination.getScheme())) { - throw new SecurityException("Unsupported export URI scheme"); - } - - String authority = destination.getAuthority(); - if (authority == null || !TRUSTED_DOCUMENT_AUTHORITIES.contains(authority)) { - throw new SecurityException("Untrusted document provider"); - } - - String path = destination.getPath(); - if (path == null) { - throw new SecurityException("Export URI has no path"); - } - - Path normalizedPath = FileSystems.getDefault().getPath(path).normalize(); - if (normalizedPath.startsWith("/data")) { - throw new SecurityException("Export URI targets private app storage"); - } - - return resolver.openOutputStream(destination); - } -} diff --git a/app/src/main/java/dev/gf2log/app/TrustedImportSource.java b/app/src/main/java/dev/gf2log/app/TrustedImportSource.java deleted file mode 100644 index 22cf85f..0000000 --- a/app/src/main/java/dev/gf2log/app/TrustedImportSource.java +++ /dev/null @@ -1,37 +0,0 @@ -package dev.gf2log.app; - -import android.content.ContentResolver; -import android.net.Uri; - -import java.io.FileNotFoundException; -import java.io.InputStream; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -final class TrustedImportSource { - private static final Set TRUSTED_DOCUMENT_AUTHORITIES = new HashSet<>( - Arrays.asList( - "com.android.externalstorage.documents", - "com.android.providers.downloads.documents", - "com.android.providers.media.documents", - "com.google.android.apps.docs.storage", - "com.google.android.apps.nbu.files.provider" - ) - ); - - private TrustedImportSource() { - } - - static InputStream openInputStream(ContentResolver resolver, Uri source) - throws FileNotFoundException { - if (!ContentResolver.SCHEME_CONTENT.equals(source.getScheme())) { - throw new SecurityException("Unsupported import URI scheme"); - } - String authority = source.getAuthority(); - if (authority == null || !TRUSTED_DOCUMENT_AUTHORITIES.contains(authority)) { - throw new SecurityException("Untrusted document provider"); - } - return resolver.openInputStream(source); - } -} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index ec50bb9..3c93047 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -107,7 +107,7 @@ internal object WeeklyPngPendingState { ) } -class WeeklyReportActivity : LocalizedActivity() { +class WeeklyReportActivity : BaseActivity() { private lateinit var repository: PlatoonRepository private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var body: LinearLayout @@ -155,8 +155,7 @@ class WeeklyReportActivity : LocalizedActivity() { ) } - override fun onResume() { - super.onResume() + override fun onScreenResumed() { if (!profileBinding.isCurrent(this)) { recreate() return @@ -1649,7 +1648,7 @@ class WeeklyReportActivity : LocalizedActivity() { val destination = data?.data if (resultCode != RESULT_OK || destination == null || source == null) return val exported = runCatching { - val output = TrustedExportDestination.openOutputStream(contentResolver, destination) + val output = dev.gf2log.app.ui.DocumentAccess.openOutput(contentResolver, destination) ?: error("Document provider did not open an output stream") output.use { target -> source.inputStream().use { input -> input.copyTo(target) } @@ -1673,7 +1672,7 @@ class WeeklyReportActivity : LocalizedActivity() { val destination = data?.data if (resultCode != RESULT_OK || destination == null || content == null) return val exported = runCatching { - val output = TrustedExportDestination.openOutputStream(contentResolver, destination) + val output = dev.gf2log.app.ui.DocumentAccess.openOutput(contentResolver, destination) ?: error("Document provider did not open an output stream") output.writer(Charsets.UTF_8).use { it.write(content) } }.isSuccess diff --git a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt index 6822577..5364cb6 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt @@ -15,7 +15,7 @@ import android.widget.Toast import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines -class WeeklySettingsActivity : LocalizedActivity() { +class WeeklySettingsActivity : BaseActivity() { private lateinit var dailyMerit: ThresholdInput private lateinit var dailyScore: ThresholdInput private lateinit var dailyAttempts: ThresholdInput diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt index edc2907..b1196a4 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt @@ -19,7 +19,7 @@ import java.time.format.FormatStyle import java.util.concurrent.Executors /** Lists the bounded immutable history for one weekly table. */ -class WeeklyTableHistoryActivity : LocalizedActivity() { +class WeeklyTableHistoryActivity : BaseActivity() { private lateinit var repository: PlatoonRepository private lateinit var body: LinearLayout private val worker = Executors.newSingleThreadExecutor { runnable -> diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt index d6e2a87..7591506 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt @@ -21,7 +21,7 @@ import java.time.LocalDate import java.util.concurrent.Executors /** Shows one immutable history projection and provides the explicit restore action. */ -class WeeklyTableHistoryPreviewActivity : LocalizedActivity() { +class WeeklyTableHistoryPreviewActivity : BaseActivity() { private lateinit var repository: PlatoonRepository private lateinit var body: LinearLayout private val worker = Executors.newSingleThreadExecutor { runnable -> diff --git a/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt b/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt new file mode 100644 index 0000000..2b62b8d --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt @@ -0,0 +1,70 @@ +package dev.gf2log.app.ui + +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * Serialized off-main-thread work bound to one screen's lifecycle. + * + * Every task runs on one worker so repository calls from a screen keep their submission order. + * 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. + */ +internal class BackgroundWork(owner: LifecycleOwner, threadName: String) { + private val main = Handler(Looper.getMainLooper()) + private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, threadName) + } + private val generations = HashMap() + private val running = HashMap() + private var destroyed = false + + init { + owner.lifecycle.addObserver(LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_DESTROY) { + destroyed = true + executor.shutdown() + } + }) + } + + /** True while a request for [key] has been submitted and its result is not yet delivered. */ + fun isBusy(key: String): Boolean = (running[key] ?: 0) > 0 + + fun run( + key: String? = null, + task: () -> T, + onResult: (Result) -> Unit, + ) { + if (destroyed) return + val generation = key?.let { generations[it] = (generations[it] ?: 0) + 1; generations[it] } + key?.let { running[it] = (running[it] ?: 0) + 1 } + executor.execute { + val result = runCatching(task) + result.exceptionOrNull()?.let { Log.w(TAG, "Background task failed", it) } + 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) + } + } + } + + /** Invalidates pending results for [key] without cancelling work already committing. */ + fun cancel(key: String) { + generations[key] = (generations[key] ?: 0) + 1 + } + + private companion object { + const val TAG = "GF2Work" + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt new file mode 100644 index 0000000..fd4077e --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt @@ -0,0 +1,125 @@ +package dev.gf2log.app.ui + +import android.app.AlertDialog +import android.content.Context +import android.view.View +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.Toast +import androidx.annotation.StringRes +import dev.gf2log.app.R + +/** App-owned dialogs: one surface, one accent, and an explicit red treatment for destruction. */ +internal object Dialogs { + fun builder(context: Context): AlertDialog.Builder = AlertDialog.Builder(context) + + /** Shows the dialog and applies the shared button palette once its buttons exist. */ + fun show( + builder: AlertDialog.Builder, + destructive: Boolean = false, + onShown: (AlertDialog) -> Unit = {}, + ): AlertDialog { + val dialog = builder.create() + dialog.setOnShowListener { + val context = dialog.context + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply { + setTextColor(context.getColor(if (destructive) R.color.destructive_action else R.color.accent_text)) + isAllCaps = false + } + listOf(AlertDialog.BUTTON_NEGATIVE, AlertDialog.BUTTON_NEUTRAL).forEach { which -> + dialog.getButton(which)?.apply { + setTextColor(context.getColor(R.color.text_secondary)) + isAllCaps = false + } + } + onShown(dialog) + } + dialog.show() + return dialog + } + + fun confirm( + context: Context, + title: CharSequence, + message: CharSequence?, + confirmLabel: CharSequence, + destructive: Boolean = false, + onConfirm: () -> Unit, + ): AlertDialog = show( + builder(context) + .setTitle(title) + .setMessage(message) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(confirmLabel) { _, _ -> onConfirm() }, + destructive = destructive, + ) + + fun confirm( + context: Context, + @StringRes title: Int, + @StringRes message: Int, + @StringRes confirmLabel: Int, + destructive: Boolean = false, + onConfirm: () -> Unit, + ): AlertDialog = confirm( + context, + context.getString(title), + context.getString(message), + context.getString(confirmLabel), + destructive, + onConfirm, + ) + + fun choose( + context: Context, + title: CharSequence, + labels: List, + checked: Int, + onChosen: (Int) -> Unit, + ): AlertDialog = show( + builder(context) + .setTitle(title) + .setSingleChoiceItems(labels.toTypedArray(), checked) { dialog, which -> + dialog.dismiss() + onChosen(which) + } + .setNegativeButton(R.string.cancel, null), + ) + + fun message(context: Context, title: CharSequence, message: CharSequence): AlertDialog = show( + builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null), + ) + + /** Padded, scrollable dialog body aligned with the platform title inset. */ + fun content(context: Context, block: LinearLayout.() -> Unit): View = ScrollView(context).apply { + addView(context.column { + setPadding(context.dp(24), context.dp(8), context.dp(24), context.dp(8)) + block() + }) + } +} + +/** + * Replaces the visible transient message instead of extending Android's Toast queue, so rapid + * repeated actions keep their feedback current. + */ +internal object Feedback { + private var active: Toast? = null + + @Synchronized + fun show(context: Context, message: CharSequence, long: Boolean = false) { + active?.cancel() + active = Toast.makeText( + context.applicationContext, + message, + if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT, + ).also(Toast::show) + } + + fun show(context: Context, @StringRes message: Int, long: Boolean = false) { + show(context, context.getString(message), long) + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt b/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt new file mode 100644 index 0000000..6b74aef --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt @@ -0,0 +1,53 @@ +package dev.gf2log.app.ui + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import java.io.InputStream +import java.io.OutputStream +import java.nio.file.Paths + +/** + * Opens only Storage Access Framework documents from the platform and Google document providers. + * A user-selected destination can never resolve into another app's private storage. + */ +internal object DocumentAccess { + private val TRUSTED_AUTHORITIES = setOf( + "com.android.externalstorage.documents", + "com.android.providers.downloads.documents", + "com.android.providers.media.documents", + "com.google.android.apps.docs.storage", + "com.google.android.apps.nbu.files.provider", + ) + + fun openOutput(resolver: ContentResolver, destination: Uri): OutputStream { + requireTrusted(destination) + val path = destination.path ?: throw SecurityException("Export URI has no path") + if (Paths.get(path).normalize().startsWith("/data")) { + throw SecurityException("Export URI targets private app storage") + } + return resolver.openOutputStream(destination, "wt") + ?: error("Document provider did not open an output stream") + } + + fun openInput(resolver: ContentResolver, source: Uri): InputStream { + requireTrusted(source) + return resolver.openInputStream(source) + ?: error("Document provider did not open an input stream") + } + + fun displayName(resolver: ContentResolver, uri: Uri): String? = runCatching { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + }.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") + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Labels.kt b/app/src/main/java/dev/gf2log/app/ui/Labels.kt new file mode 100644 index 0000000..d276faf --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/Labels.kt @@ -0,0 +1,91 @@ +package dev.gf2log.app.ui + +import android.content.Context +import dev.gf2log.app.R +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.protocol.Gfl2PayloadDecoder +import dev.gf2log.protocol.PayloadCatalog +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.time.temporal.TemporalAccessor + +/** Localized presentation labels shared by every screen; no domain policy lives here. */ +internal object Labels { + val DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("yy/MM/dd") + val DATE_TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm") + val DATE_TIME_SECONDS: DateTimeFormatter = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") + val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + val FILE_STAMP: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") + val FILE_DATE: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE + + fun format(formatter: DateTimeFormatter, instant: Instant, zone: ZoneId): String = + formatter.format(instant.atZone(zone)) + + fun format(formatter: DateTimeFormatter, value: TemporalAccessor): String = formatter.format(value) + + /** "10/12 Sun" with the weekday in the active app language. */ + fun dayHeader(context: Context, day: java.time.LocalDate): String = + "${day.monthValue}/${day.dayOfMonth} " + + day.dayOfWeek.getDisplayName(TextStyle.SHORT, context.resources.configuration.locales[0]) + + fun region(context: Context, region: GameServerRegion): String = context.getString( + when (region) { + GameServerRegion.MANUAL -> R.string.server_region_manual + GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global + GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china + GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global + GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan + GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea + GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia + }, + ) + + /** "HaoPlay · South Korea · ID 1234" */ + fun profileIdentity(context: Context, profile: PlatoonProfile): String = context.getString( + R.string.profile_identity, + profile.client.displayName, + region(context, profile.serverRegion), + profile.platoonId, + ) + + fun payloadName(context: Context, payloadType: Int?): String = when (payloadType) { + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> context.getString(R.string.payload_name_platoon_profile) + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> context.getString(R.string.payload_name_platoon_members) + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> context.getString(R.string.payload_name_platoon_activity) + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> context.getString(R.string.payload_name_platoon_updates) + Gfl2PayloadDecoder.TYPE_WEAPONS -> context.getString(R.string.payload_name_weapons) + Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> context.getString(R.string.payload_name_attachments) + Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> context.getString(R.string.payload_name_common_keys) + Gfl2PayloadDecoder.TYPE_FORMATIONS -> context.getString(R.string.payload_name_formations) + else -> context.getString(R.string.unknown_payload_type) + } + + fun payloadTag(context: Context, payloadType: Int?): String = when (payloadType) { + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> context.getString(R.string.payload_tag_profile) + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> context.getString(R.string.payload_tag_platoon) + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> context.getString(R.string.payload_tag_activity) + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> context.getString(R.string.payload_tag_updates) + Gfl2PayloadDecoder.TYPE_WEAPONS -> context.getString(R.string.payload_tag_weapons) + Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> context.getString(R.string.payload_tag_attachments) + Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> context.getString(R.string.payload_tag_common_keys) + Gfl2PayloadDecoder.TYPE_FORMATIONS -> context.getString(R.string.payload_tag_formations) + else -> PayloadCatalog.tag(payloadType) + } + + fun payloadDescription(context: Context, payloadType: Int): String = context.getString( + when (payloadType) { + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> R.string.payload_description_platoon_profile + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> R.string.payload_description_platoon_members + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> R.string.payload_description_platoon_activity + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> R.string.payload_description_platoon_updates + Gfl2PayloadDecoder.TYPE_WEAPONS -> R.string.payload_description_weapons + Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> R.string.payload_description_attachments + Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> R.string.payload_description_common_keys + Gfl2PayloadDecoder.TYPE_FORMATIONS -> R.string.payload_description_formations + else -> R.string.unknown_payload_type + }, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Screen.kt b/app/src/main/java/dev/gf2log/app/ui/Screen.kt new file mode 100644 index 0000000..3e1943b --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/Screen.kt @@ -0,0 +1,94 @@ +package dev.gf2log.app.ui + +import android.app.Activity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.HorizontalScrollView +import android.widget.LinearLayout +import android.widget.ScrollView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.recyclerview.widget.RecyclerView +import dev.gf2log.app.PrimaryNavigation +import dev.gf2log.app.R + +/** + * Edge-to-edge screen shell: top bar, replaceable body, and the optional three-destination + * bottom bar. Window insets are applied here once so no Activity handles status, navigation, + * cutout, or keyboard insets on its own. + */ +internal class Screen( + private val activity: Activity, + destination: PrimaryNavigation.Destination? = null, +) { + val topBar = TopBar(activity) + private val content = FrameLayout(activity) + private val bottomBar = destination?.let { PrimaryNavigation.bar(activity, it) } + private var bottomInset = 0 + private var body: View? = null + + val root: LinearLayout = activity.column { + setBackgroundColor(activity.getColor(R.color.app_background)) + addView(topBar, activity.lp()) + addView(content, activity.lp(height = 0, weight = 1f)) + bottomBar?.let { + addView(activity.divider()) + addView(it, activity.lp()) + } + } + + init { + ViewCompat.setOnApplyWindowInsetsListener(root) { view, windowInsets -> + val bars = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) + val ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) + view.setPadding(bars.left, 0, bars.right, 0) + topBar.setPadding(topBar.paddingLeft, bars.top, topBar.paddingRight, 0) + bottomInset = if (bottomBar != null) { + bottomBar.setPadding(0, 0, 0, bars.bottom) + maxOf(0, ime.bottom - bars.bottom) + } else { + maxOf(bars.bottom, ime.bottom) + } + applyBottomInset() + WindowInsetsCompat.CONSUMED + } + activity.setContentView(root) + } + + /** Replaces the body; scrolling bodies draw beneath the navigation bar and pad their end. */ + fun setBody(view: View) { + content.removeAllViews() + body = view + content.addView(view, FrameLayout.LayoutParams(MATCH, MATCH)) + applyBottomInset() + } + + private fun applyBottomInset() { + val target = body ?: return + val scrolls = target is ScrollView || target is RecyclerView || target is HorizontalScrollView + val padded = if (scrolls) target else content + val base = (padded.getTag(R.id.gf2_base_bottom_padding) as? Int) + ?: padded.paddingBottom.also { padded.setTag(R.id.gf2_base_bottom_padding, it) } + padded.setPadding(padded.paddingLeft, padded.paddingTop, padded.paddingRight, base + bottomInset) + if (padded is ViewGroup) padded.clipToPadding = false + } + + companion object { + /** Standard vertically scrolling body with the 16dp screen inset from DESIGN.md. */ + fun scrollingBody(activity: Activity, column: LinearLayout): ScrollView = + ScrollView(activity).apply { + id = R.id.screen_scroll + isFillViewport = true + column.setPadding( + activity.dp(16), + activity.dp(4), + activity.dp(16), + activity.dp(24), + ) + addView(column, FrameLayout.LayoutParams(MATCH, WRAP)) + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/StateViews.kt b/app/src/main/java/dev/gf2log/app/ui/StateViews.kt new file mode 100644 index 0000000..ceea756 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/StateViews.kt @@ -0,0 +1,64 @@ +package dev.gf2log.app.ui + +import android.content.Context +import android.view.Gravity +import android.view.View +import android.widget.ProgressBar +import androidx.annotation.DrawableRes +import dev.gf2log.app.ModernUi +import dev.gf2log.app.R + +/** Loading, empty, and failure states share one centered composition on every screen. */ +internal object StateViews { + fun loading(context: Context, message: CharSequence): View = context.column { + gravity = Gravity.CENTER_HORIZONTAL + setPadding(context.dp(24), context.dp(48), context.dp(24), context.dp(48)) + addView(ProgressBar(context).apply { + isIndeterminate = true + 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) { + gravity = Gravity.CENTER + setPadding(0, context.dp(12), 0, 0) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + }, context.lp()) + } + + fun empty( + context: Context, + @DrawableRes icon: Int, + title: CharSequence, + detail: CharSequence? = null, + actionLabel: CharSequence? = null, + onAction: (() -> Unit)? = null, + ): View = context.column { + 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) { + gravity = Gravity.CENTER + setPadding(0, context.dp(12), 0, 0) + }, context.lp()) + detail?.let { + addView(context.text(it, TextRole.SECONDARY) { + gravity = Gravity.CENTER + setPadding(0, context.dp(6), 0, 0) + }, context.lp()) + } + if (actionLabel != null && onAction != null) { + addView( + context.button(actionLabel, ModernUi.ControlRole.SECONDARY) { onAction() }, + context.lp(WRAP, WRAP, top = 16), + ) + } + } + + fun failure(context: Context, message: CharSequence, onRetry: () -> Unit): View = empty( + context, + R.drawable.ic_warning, + message, + actionLabel = context.getString(R.string.retry), + onAction = onRetry, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/ui/TopBar.kt b/app/src/main/java/dev/gf2log/app/ui/TopBar.kt new file mode 100644 index 0000000..7a6a88a --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/TopBar.kt @@ -0,0 +1,118 @@ +package dev.gf2log.app.ui + +import android.content.Context +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.PopupMenu +import android.widget.TextView +import androidx.annotation.DrawableRes +import dev.gf2log.app.R + +/** One trailing top-bar control. Icon-only actions always carry an accessible label. */ +internal data class BarAction( + @DrawableRes val icon: Int, + val label: CharSequence, + val enabled: Boolean = true, + val onClick: (View) -> Unit, +) + +/** One overflow-menu entry; overflow keeps secondary utilities quiet. */ +internal data class MenuEntry( + val label: CharSequence, + val enabled: Boolean = true, + val onClick: () -> Unit, +) + +/** + * Compact app bar shared by every screen. The status-bar inset is applied by [Screen]; this view + * owns only its 56dp content row. + */ +internal class TopBar(context: Context) : LinearLayout(context) { + private val navigation = context.iconButton(R.drawable.ic_arrow_back, context.getString(R.string.back)) {} + private val leading = context.frame() + private val titleView = context.text(null, TextRole.TITLE) { + textSize = 19f + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + accessibilityHeading() + } + private val subtitleView = context.text(null, TextRole.CAPTION) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + visibility = View.GONE + } + private val actions = context.row() + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = context.dp(56) + setPadding(context.dp(4), 0, context.dp(4), 0) + setBackgroundColor(context.getColor(R.color.app_background)) + navigation.visibility = View.GONE + leading.visibility = View.GONE + addView(navigation, context.lp(48, 48)) + addView(leading, context.lp(WRAP, WRAP, start = 8, end = 4)) + addView(context.column { + gravity = Gravity.CENTER_VERTICAL + addView(titleView, context.lp()) + addView(subtitleView, context.lp()) + }, context.lp(width = 0, weight = 1f, start = 8, end = 4)) + addView(actions, context.lp(WRAP, WRAP)) + } + + val title: TextView get() = titleView + + fun setTitle(value: CharSequence?, subtitle: CharSequence? = null): TopBar = apply { + titleView.text = value + subtitleView.text = subtitle + subtitleView.visibility = if (subtitle.isNullOrBlank()) View.GONE else View.VISIBLE + } + + fun setNavigation(onBack: (() -> Unit)?): TopBar = apply { + navigation.visibility = if (onBack == null) View.GONE else View.VISIBLE + navigation.setOnClickListener { onBack?.invoke() } + } + + fun setNavigationIcon(@DrawableRes icon: Int, description: CharSequence): TopBar = apply { + navigation.setImageResource(icon) + navigation.contentDescription = description + } + + fun setLeading(view: View?): TopBar = apply { + leading.removeAllViews() + leading.visibility = if (view == null) View.GONE else View.VISIBLE + view?.let { leading.addView(it) } + } + + fun setActions(items: List, overflow: List = emptyList()): TopBar = apply { + actions.removeAllViews() + items.forEach { action -> + actions.addView(context.iconButton(action.icon, action.label, onClick = action.onClick).apply { + isEnabled = action.enabled + }, context.lp(48, 48)) + } + if (overflow.isNotEmpty()) { + actions.addView( + context.iconButton(R.drawable.ic_more_vert, context.getString(R.string.more_options)) { anchor -> + showMenu(anchor, overflow) + }, + context.lp(48, 48), + ) + } + } + + private fun showMenu(anchor: View, entries: List) { + PopupMenu(context, anchor, Gravity.END).apply { + entries.forEachIndexed { index, entry -> + menu.add(0, index, index, entry.label).isEnabled = entry.enabled + } + setOnMenuItemClickListener { item -> + entries.getOrNull(item.itemId)?.onClick?.invoke() + true + } + }.show() + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Views.kt b/app/src/main/java/dev/gf2log/app/ui/Views.kt new file mode 100644 index 0000000..0bcb787 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -0,0 +1,415 @@ +package dev.gf2log.app.ui + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.text.InputFilter +import android.text.InputType +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.Switch +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import dev.gf2log.app.ModernUi +import dev.gf2log.app.R + +const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT +const val WRAP = ViewGroup.LayoutParams.WRAP_CONTENT + +internal fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + +internal fun Context.dpf(value: Float): Float = value * resources.displayMetrics.density + +/** Linear layout parameters expressed in dp; MATCH and WRAP pass through unchanged. */ +internal fun Context.lp( + width: Int = MATCH, + height: Int = WRAP, + weight: Float = 0f, + top: Int = 0, + bottom: Int = 0, + start: Int = 0, + end: Int = 0, +): LinearLayout.LayoutParams = LinearLayout.LayoutParams( + if (width < 0) width else dp(width), + if (height < 0) height else dp(height), + weight, +).apply { + topMargin = dp(top) + bottomMargin = dp(bottom) + marginStart = dp(start) + marginEnd = dp(end) +} + +internal fun Context.column( + padding: Int = 0, + block: LinearLayout.() -> Unit = {}, +): LinearLayout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + if (padding > 0) setPadding(dp(padding), dp(padding), dp(padding), dp(padding)) + block() +} + +internal fun Context.row( + gravity: Int = Gravity.CENTER_VERTICAL, + block: LinearLayout.() -> Unit = {}, +): LinearLayout = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + this.gravity = gravity + block() +} + +/** Typography roles from DESIGN.md; sizes follow the user's font scale. */ +internal enum class TextRole( + val sizeSp: Float, + val family: String, + val style: Int, + @ColorRes val color: Int, +) { + HEADLINE(22f, "sans-serif", Typeface.BOLD, R.color.text_primary), + TITLE(17f, "sans-serif-medium", Typeface.NORMAL, R.color.text_primary), + SUBTITLE(15f, "sans-serif-medium", Typeface.NORMAL, R.color.text_primary), + BODY(14f, "sans-serif", Typeface.NORMAL, R.color.text_primary), + SECONDARY(13f, "sans-serif", Typeface.NORMAL, R.color.text_secondary), + LABEL(12f, "sans-serif-medium", Typeface.NORMAL, R.color.text_secondary), + CAPTION(12f, "sans-serif", Typeface.NORMAL, R.color.text_secondary), +} + +internal fun Context.text( + value: CharSequence?, + role: TextRole = TextRole.BODY, + block: TextView.() -> Unit = {}, +): TextView = TextView(this).apply { + text = value + applyRole(role) + block() +} + +internal fun TextView.applyRole(role: TextRole) { + textSize = role.sizeSp + typeface = Typeface.create(role.family, role.style) + setTextColor(context.getColor(role.color)) + setLineSpacing(0f, if (role == TextRole.HEADLINE) 1.05f else 1.15f) +} + +/** Section label used above grouped rows. */ +internal fun Context.sectionHeader(value: CharSequence): TextView = text(value, TextRole.LABEL) { + isAllCaps = false + setTextColor(getColor(R.color.accent_text)) + setPadding(dp(4), dp(20), dp(4), dp(6)) + isFocusable = false + accessibilityHeading() +} + +internal fun View.accessibilityHeading() { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + isAccessibilityHeading = true + } +} + +internal fun Context.button( + label: CharSequence, + role: ModernUi.ControlRole = ModernUi.ControlRole.SECONDARY, + @DrawableRes icon: Int? = null, + onClick: (View) -> Unit, +): Button = Button(this).apply { + text = label + setTag(R.id.gf2_ui_role, role) + ModernUi.styleButton(this) + icon?.let { drawable -> + val image = requireNotNull(getDrawable(drawable)).mutate() + image.setTint(currentTextColor) + val size = dp(18) + image.setBounds(0, 0, size, size) + setCompoundDrawablesRelative(image, null, null, null) + compoundDrawablePadding = dp(8) + } + setOnClickListener(onClick) +} + +internal fun Context.iconButton( + @DrawableRes icon: Int, + description: CharSequence, + @ColorRes tint: Int = R.color.text_primary, + onClick: (View) -> Unit, +): ImageButton = ImageButton(this).apply { + setImageResource(icon) + contentDescription = description + imageTintList = ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), + intArrayOf(getColor(R.color.outline_strong), getColor(tint)), + ) + background = selectableBorderless() + scaleType = ImageView.ScaleType.CENTER + minimumWidth = dp(48) + minimumHeight = dp(48) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + tooltipText = description + } + setOnClickListener(onClick) +} + +internal fun Context.icon( + @DrawableRes icon: Int, + @ColorRes tint: Int = R.color.text_secondary, + sizeDp: Int = 24, +): ImageView = ImageView(this).apply { + setImageResource(icon) + imageTintList = ColorStateList.valueOf(getColor(tint)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + layoutParams = LinearLayout.LayoutParams(dp(sizeDp), dp(sizeDp)) +} + +internal fun Context.textField( + hintText: CharSequence, + @IdRes id: Int = View.NO_ID, + inputType: Int = InputType.TYPE_CLASS_TEXT, + singleLine: Boolean = true, + maxLength: Int? = null, +): EditText = EditText(this).apply { + if (id != View.NO_ID) this.id = id + hint = hintText + this.inputType = if (singleLine) inputType else inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE + if (singleLine) setSingleLine(true) else minLines = 2 + maxLength?.let { filters = arrayOf(InputFilter.LengthFilter(it)) } + elevation = 0f +} + +/** Labeled input with an optional supporting or error line. */ +internal class LabeledField( + context: Context, + label: CharSequence, + val input: EditText, + private val supporting: CharSequence? = null, +) : LinearLayout(context) { + private val message = context.text(supporting, TextRole.CAPTION) { + setPadding(context.dp(4), context.dp(4), context.dp(4), 0) + visibility = if (supporting.isNullOrBlank()) View.GONE else View.VISIBLE + } + + init { + orientation = VERTICAL + addView(context.text(label, TextRole.LABEL) { + setPadding(context.dp(4), 0, context.dp(4), context.dp(4)) + labelFor = input.id.takeIf { it != View.NO_ID } ?: View.generateViewId().also { input.id = it } + }, context.lp()) + addView(input, context.lp()) + addView(message, context.lp()) + } + + fun showError(error: CharSequence?) { + message.text = error ?: supporting + message.setTextColor( + context.getColor(if (error == null) R.color.text_secondary else R.color.destructive_action), + ) + message.visibility = if (message.text.isNullOrBlank()) View.GONE else View.VISIBLE + if (error != null) announceForAccessibility(error) + } +} + +internal fun Context.checkBox( + label: CharSequence, + checked: Boolean, + onChanged: (Boolean) -> Unit = {}, +): CheckBox = CheckBox(this).apply { + text = label + isChecked = checked + textSize = 14f + setTextColor(getColor(R.color.text_primary)) + buttonTintList = ModernUi.controlTint(this@checkBox) + minimumHeight = dp(48) + setOnCheckedChangeListener { _, value -> onChanged(value) } +} + +/** Full-row switch: the complete row toggles and exposes one accessible control. */ +internal fun Context.switchRow( + title: CharSequence, + detail: CharSequence?, + checked: Boolean, + enabled: Boolean = true, + badge: CharSequence? = null, + onChanged: (Boolean) -> Unit, +): View { + val toggle = Switch(this).apply { + isChecked = checked + isEnabled = enabled + thumbTintList = ModernUi.switchThumbTint(this@switchRow) + trackTintList = ModernUi.switchTrackTint(this@switchRow) + contentDescription = listOfNotNull(title, badge, detail).joinToString(". ") + setOnCheckedChangeListener { _, value -> onChanged(value) } + } + return row { + minimumHeight = dp(if (detail.isNullOrBlank()) 52 else 64) + setPadding(dp(4), dp(8), 0, dp(8)) + background = selectableRect() + isEnabled = enabled + if (enabled) setOnClickListener { toggle.toggle() } + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(row { + addView(text(title, TextRole.BODY) { + if (!enabled) setTextColor(getColor(R.color.text_secondary)) + }, lp(width = 0, weight = 1f)) + badge?.let { addView(badge(it), lp(width = WRAP, start = 8)) } + }, lp()) + detail?.takeIf(CharSequence::isNotBlank)?.let { + addView(text(it, TextRole.CAPTION) { setPadding(0, dp(2), 0, 0) }, lp()) + } + }, lp(width = 0, weight = 1f)) + addView(toggle, lp(width = WRAP, start = 12)) + } +} + +/** Compact non-interactive status or category label. */ +internal fun Context.badge( + value: CharSequence, + @ColorRes background: Int = R.color.surface_variant, + @ColorRes foreground: Int = R.color.text_secondary, +): TextView = text(value, TextRole.LABEL) { + setTextColor(getColor(foreground)) + textSize = 11f + gravity = Gravity.CENTER + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setPadding(dp(8), dp(3), dp(8), dp(3)) + this.background = GradientDrawable().apply { + setColor(getColor(background)) + cornerRadius = dpf(8f) + } +} + +internal fun Context.divider(insetStartDp: Int = 0): View = View(this).apply { + setBackgroundColor(getColor(R.color.outline)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + layoutParams = LinearLayout.LayoutParams(MATCH, maxOf(1, dp(1))).apply { + marginStart = dp(insetStartDp) + } +} + +/** Bounded tonal group for related rows; DESIGN.md "one tonal step" surface. */ +internal fun Context.panel( + emphasized: Boolean = false, + outlined: Boolean = false, + padding: Int = 16, + block: LinearLayout.() -> Unit = {}, +): LinearLayout = column { + setPadding(dp(padding), dp(padding), dp(padding), dp(padding)) + background = GradientDrawable().apply { + setColor(getColor(if (emphasized) R.color.accent_surface else R.color.surface)) + cornerRadius = dpf(16f) + if (outlined) setStroke(dp(1), getColor(R.color.outline)) + } + block() +} + +/** + * Grouped list container: rows are separated by inset hairlines and share one rounded surface, + * matching the Settings composition in PRODUCT.md. + */ +internal fun Context.group(rows: List): LinearLayout = column { + background = GradientDrawable().apply { + setColor(getColor(R.color.surface)) + cornerRadius = dpf(16f) + } + clipToOutline = true + setPadding(dp(8), dp(4), dp(8), dp(4)) + rows.forEachIndexed { index, view -> + if (index > 0) addView(divider(insetStartDp = 8)) + addView(view, lp()) + } +} + +/** A tappable grouped row with an optional icon, supporting text, and trailing value. */ +internal fun Context.navigationRow( + title: CharSequence, + detail: CharSequence? = null, + @DrawableRes icon: Int? = null, + trailing: CharSequence? = null, + destructive: Boolean = false, + enabled: Boolean = true, + showChevron: Boolean = true, + onClick: () -> Unit, +): View = 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() } + val primaryColor = when { + !enabled -> R.color.text_secondary + destructive -> R.color.destructive_action + else -> R.color.text_primary + } + icon?.let { + addView(icon(it, if (destructive) R.color.destructive_action else R.color.text_secondary), lp( + width = 24, + height = 24, + end = 16, + )) + } + addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(text(title, TextRole.BODY) { + setTextColor(getColor(primaryColor)) + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + }, lp()) + detail?.takeIf(CharSequence::isNotBlank)?.let { + addView(text(it, TextRole.CAPTION) { setPadding(0, dp(2), 0, 0) }, lp()) + } + }, lp(width = 0, weight = 1f)) + trailing?.let { + addView(text(it, TextRole.SECONDARY) { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + maxWidth = dp(160) + }, lp(width = WRAP, start = 8)) + } + if (showChevron) addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) +} + +/** Exposes a composite row to accessibility services as one button. */ +internal class AccessibleRow(context: Context) : LinearLayout(context) { + override fun getAccessibilityClassName(): CharSequence = Button::class.java.name +} + +internal fun Context.selectableRect(): android.graphics.drawable.Drawable = + android.graphics.drawable.RippleDrawable( + ColorStateList.valueOf(getColor(R.color.control_ripple)), + null, + GradientDrawable().apply { + setColor(getColor(R.color.surface)) + cornerRadius = dpf(12f) + }, + ) + +internal fun Context.selectableBorderless(): android.graphics.drawable.Drawable = + android.graphics.drawable.RippleDrawable( + ColorStateList.valueOf(getColor(R.color.control_ripple)), + null, + null, + ) + +internal fun Context.spacer(heightDp: Int): View = View(this).apply { + layoutParams = LinearLayout.LayoutParams(MATCH, dp(heightDp)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO +} + +internal fun Context.frame(block: FrameLayout.() -> Unit = {}): FrameLayout = + FrameLayout(this).apply(block) diff --git a/app/src/main/res/drawable/ic_arrow_down.xml b/app/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 0000000..1dc8252 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_up.xml b/app/src/main/res/drawable/ic_arrow_up.xml new file mode 100644 index 0000000..5665383 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bolt.xml b/app/src/main/res/drawable/ic_bolt.xml new file mode 100644 index 0000000..2c13ff0 --- /dev/null +++ b/app/src/main/res/drawable/ic_bolt.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bug.xml b/app/src/main/res/drawable/ic_bug.xml new file mode 100644 index 0000000..be4495c --- /dev/null +++ b/app/src/main/res/drawable/ic_bug.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..9068917 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..63d572f --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_left.xml b/app/src/main/res/drawable/ic_chevron_left.xml new file mode 100644 index 0000000..6e12ab6 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_left.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_chevron_next.xml b/app/src/main/res/drawable/ic_chevron_next.xml new file mode 100644 index 0000000..64b71dd --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_next.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_circle_outline.xml b/app/src/main/res/drawable/ic_circle_outline.xml new file mode 100644 index 0000000..13a15fc --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..c156145 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_code.xml b/app/src/main/res/drawable/ic_code.xml new file mode 100644 index 0000000..305b5d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_code.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_compare.xml b/app/src/main/res/drawable/ic_compare.xml new file mode 100644 index 0000000..fa3beb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_compare.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 0000000..da6770d --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..d4a2507 --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 0000000..87241b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..9337852 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_notes.xml b/app/src/main/res/drawable/ic_notes.xml new file mode 100644 index 0000000..d36a00f --- /dev/null +++ b/app/src/main/res/drawable/ic_notes.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_palette.xml b/app/src/main/res/drawable/ic_palette.xml new file mode 100644 index 0000000..add9393 --- /dev/null +++ b/app/src/main/res/drawable/ic_palette.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..b1fd870 --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_add.xml b/app/src/main/res/drawable/ic_person_add.xml new file mode 100644 index 0000000..b17aea5 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..51dc2ea --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..3a66cde --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_shield.xml b/app/src/main/res/drawable/ic_shield.xml new file mode 100644 index 0000000..74ebb66 --- /dev/null +++ b/app/src/main/res/drawable/ic_shield.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml new file mode 100644 index 0000000..1d79db4 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stop.xml b/app/src/main/res/drawable/ic_stop.xml new file mode 100644 index 0000000..4501239 --- /dev/null +++ b/app/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_storage.xml b/app/src/main/res/drawable/ic_storage.xml new file mode 100644 index 0000000..ef64421 --- /dev/null +++ b/app/src/main/res/drawable/ic_storage.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_table.xml b/app/src/main/res/drawable/ic_table.xml new file mode 100644 index 0000000..63848dd --- /dev/null +++ b/app/src/main/res/drawable/ic_table.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_today.xml b/app/src/main/res/drawable/ic_today.xml new file mode 100644 index 0000000..c0c785f --- /dev/null +++ b/app/src/main/res/drawable/ic_today.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_translate.xml b/app/src/main/res/drawable/ic_translate.xml new file mode 100644 index 0000000..aba5c34 --- /dev/null +++ b/app/src/main/res/drawable/ic_translate.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_tune.xml b/app/src/main/res/drawable/ic_tune.xml new file mode 100644 index 0000000..7ce6ae6 --- /dev/null +++ b/app/src/main/res/drawable/ic_tune.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_undo.xml b/app/src/main/res/drawable/ic_undo.xml new file mode 100644 index 0000000..1522898 --- /dev/null +++ b/app/src/main/res/drawable/ic_undo.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_upload.xml b/app/src/main/res/drawable/ic_upload.xml new file mode 100644 index 0000000..7ec5489 --- /dev/null +++ b/app/src/main/res/drawable/ic_upload.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..aa1664b --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 38473ec..9f3b997 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -460,4 +460,7 @@ 아직 수집된 서클 정보가 없습니다. HaoPlay 캡처 서버 Darkwinter 캡처 서버 + 더보기 + %1$s · %2$s · ID %3$d + 프로필 diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 6b750ae..5924f54 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -2,4 +2,12 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 475f923..7cb1d5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -464,4 +464,7 @@ No Platoon information has been collected yet. HaoPlay capture server Darkwinter capture server + More options + %1$s · %2$s · ID %3$d + PROFILE From 2111a4b3db5efebbf1c562862ca8dc011b4c884a Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 10:57:28 +0900 Subject: [PATCH 008/125] fix: scope CSV checkpoint claims and always release them --- .../PlatoonBackupManagerIntegrationTest.kt | 26 ++++++ .../management/CsvImportCheckpointManager.kt | 81 +++++++++++++------ 2 files changed, 81 insertions(+), 26 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 c25dcc7..bbb96e4 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -498,6 +498,32 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) } + @Test + fun csvImportClaimsAreScopedAndReleasedAfterAFailedImport() { + seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") + val otherScope = PlatoonStorageScope( + PlatoonProfileRegistry(context).upsertDetected( + SupportedGamePackages.DARKWINTER, + GameServerRegion.DARKWINTER_GLOBAL, + dev.gf2log.protocol.model.PlatoonProfileData(202u, "Other Platoon", 0u, 0u), + ).storageId, + ) + val pending = CsvImportCheckpointManager(context, storageScope) + pending.create(emptySet()) + + CsvImportCheckpointManager(context, otherScope).runImport(emptySet()) {} + pending.seal() + + assertThrows(IllegalStateException::class.java) { + CsvImportCheckpointManager(context, storageScope).runImport(emptySet()) { + error("simulated import failure") + } + } + val retried = CsvImportCheckpointManager(context, storageScope) + retried.runImport(emptySet()) {} + assertTrue(retried.canUndo()) + } + @Test fun interruptedCsvUndoCompletesAfterDatabaseInstallationOnRestart() { seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt index e50c7e1..c542a54 100644 --- a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt @@ -36,14 +36,19 @@ class CsvImportCheckpointManager internal constructor( private val staging = File(profileRoot, STAGING_DIRECTORY) private val previous = File(profileRoot, PREVIOUS_DIRECTORY) + /** Claims are per scope: an operation in one Platoon never blocks another Platoon's import. */ + private val scopeState = synchronized(SCOPE_STATES) { + SCOPE_STATES.getOrPut(storageScope.storageId, ::ScopeState) + } + init { - synchronized(STATE_LOCK) { - if (!operationInProgress) recoverInterruptedState() + synchronized(scopeState) { + if (!scopeState.operationInProgress) recoverInterruptedState() } } - fun canUndo(): Boolean = synchronized(STATE_LOCK) { - !operationInProgress && hasBaseCheckpoint() && digest(root).isFile + fun canUndo(): Boolean = synchronized(scopeState) { + !scopeState.operationInProgress && hasBaseCheckpoint() && digest(root).isFile } private fun hasBaseCheckpoint(): Boolean = archive(root).isFile && manifest(root).isFile @@ -97,23 +102,44 @@ class CsvImportCheckpointManager internal constructor( /** Seals a successful import so undo is refused after any later database mutation. */ fun seal() { - check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } - val target = digest(root) - check(!target.exists()) { "CSV import checkpoint is already sealed" } - val temporary = File(root, "$DIGEST_FILE.tmp") try { - FileOutputStream(temporary).use { output -> - output.write( - backupManager().currentDatabaseSha256().toByteArray(Charsets.US_ASCII), - ) - output.fd.sync() + check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } + val target = digest(root) + check(!target.exists()) { "CSV import checkpoint is already sealed" } + val temporary = File(root, "$DIGEST_FILE.tmp") + try { + FileOutputStream(temporary).use { output -> + output.write( + backupManager().currentDatabaseSha256().toByteArray(Charsets.US_ASCII), + ) + output.fd.sync() + } + check(temporary.renameTo(target)) { "Unable to seal the CSV import checkpoint" } + } finally { + temporary.delete() } - check(temporary.renameTo(target)) { "Unable to seal the CSV import checkpoint" } + deleteDirectory(previous) } finally { - temporary.delete() + releaseOperation() + } + } + + /** + * Runs one complete import: checkpoint, [apply], then seal. Any failure, including an + * [Error], rolls the import back, and the scope's claim is always released. + */ + fun runImport(plannedFileNames: Set, apply: () -> T): T { + create(plannedFileNames) + try { + val result = apply() + seal() + return result + } catch (failure: Throwable) { + runCatching(::rollbackFailedImport).exceptionOrNull()?.let(failure::addSuppressed) + throw failure + } finally { + releaseOperation() } - deleteDirectory(previous) - releaseOperation() } // Function Name: restore @@ -265,18 +291,24 @@ class CsvImportCheckpointManager internal constructor( } private fun claimOperation() { - synchronized(STATE_LOCK) { - check(!operationInProgress) { "Another CSV checkpoint operation is in progress" } - operationInProgress = true + synchronized(scopeState) { + check(!scopeState.operationInProgress) { + "Another CSV checkpoint operation is in progress" + } + scopeState.operationInProgress = true } } private fun releaseOperation() { - synchronized(STATE_LOCK) { - operationInProgress = false + synchronized(scopeState) { + scopeState.operationInProgress = false } } + private class ScopeState { + var operationInProgress = false + } + /** * Always binds restore recovery to this scope's settings. The global store would roll an * interrupted scoped restore back into the legacy keys instead of the Platoon's own settings. @@ -306,10 +338,7 @@ class CsvImportCheckpointManager internal constructor( private fun restoreMarker(directory: File) = File(directory, RESTORE_MARKER) private companion object { - val STATE_LOCK = Any() - - @Volatile - var operationInProgress = false + val SCOPE_STATES = mutableMapOf() const val CHECKPOINT_DIRECTORY = "csv-import-checkpoint" const val STAGING_DIRECTORY = "csv-import-checkpoint.staging" From 663b5fbab52dc75b4cbf946f72056b8417576a3a Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 10:59:46 +0900 Subject: [PATCH 009/125] fix: take the maintenance lock before the profile registry lock --- .../PlatoonProfileRegistryIntegrationTest.kt | 39 ++++++++++ .../gf2log/app/management/PlatoonProfile.kt | 77 ++++++++----------- .../PlatoonProfileAdministration.kt | 3 + .../app/management/PlatoonRepository.kt | 8 ++ 4 files changed, 81 insertions(+), 46 deletions(-) diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt index 5ead2b9..4e3e41c 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt @@ -452,6 +452,45 @@ class PlatoonProfileRegistryIntegrationTest { assertEquals(null, WeeklyCutlinePreferences(context, deleted.storageId).read().dailyMerit) } + @Test + fun pendingDeletionRecoveryCannotDeadlockWithAnExclusiveRestore() { + val registry = PlatoonProfileRegistry(context) + val kept = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(1u, "Kept", 0u, 0u), + ) + val pendingId = PlatoonProfileIdentity.randomStorageId() + context.getSharedPreferences("platoon_profile_deletions", android.content.Context.MODE_PRIVATE) + .edit().putStringSet("pending_storage_ids", setOf(pendingId)).commit() + val exclusiveHeld = java.util.concurrent.CountDownLatch(1) + val failures = java.util.concurrent.ConcurrentLinkedQueue() + val restore = Thread { + runCatching { + PlatoonRepository.withExclusiveDatabase(PlatoonStorageScope(kept.storageId)) { + exclusiveHeld.countDown() + Thread.sleep(300) + registry.find(kept.storageId) + } + }.onFailure(failures::add) + }.apply { isDaemon = true } + val reader = Thread { + runCatching { + exclusiveHeld.await() + registry.list() + }.onFailure(failures::add) + }.apply { isDaemon = true } + + restore.start() + reader.start() + restore.join(10_000) + reader.join(10_000) + + assertFalse("registry and maintenance locks deadlocked", restore.isAlive || reader.isAlive) + assertTrue(failures.isEmpty()) + assertEquals(listOf(kept.storageId), registry.list().map(PlatoonProfile::storageId)) + } + @Test fun registryRejectsUnboundedNewProfilesAndCanRemoveInactiveMetadata() { val registry = PlatoonProfileRegistry(context) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt index e9f5b7f..7faadb4 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt @@ -62,6 +62,10 @@ internal data class PlatoonProfile( } } + /** True for the one non-legacy profile that owns this verified full identity. */ + fun hasIdentity(client: PlatoonClient, region: GameServerRegion, platoonId: Long): Boolean = + !legacy && this.client == client && serverRegion == region && this.platoonId == platoonId + companion object { const val MAX_BANNER_ID = 6L const val MAX_NAME_LENGTH = 128 @@ -132,29 +136,21 @@ internal class PlatoonProfileRegistry(context: Context) { private val appContext = context.applicationContext private val preferences = appContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) - fun ensureInitialized(): List = synchronized(lock) { - PlatoonProfileAdministration.recoverPending(appContext) - retireLegacyMetadataLocked() - readAllLocked().filterNot(PlatoonProfile::legacy) - } + fun ensureInitialized(): List = initialized { it } - fun list(): List = synchronized(lock) { - ensureInitialized() - readAllLocked().filterNot(PlatoonProfile::legacy).sortedWith( + fun list(): List = initialized { profiles -> + profiles.sortedWith( compareByDescending { it.lastSeenAt }.thenBy { it.storageId }, ) } - fun active(): PlatoonProfile? = synchronized(lock) { - ensureInitialized() + fun active(): PlatoonProfile? = initialized { current -> val activeId = preferences.getString(KEY_ACTIVE, null) - val current = readAllLocked().filterNot(PlatoonProfile::legacy) current.firstOrNull { it.storageId == activeId } ?: current.maxByOrNull(PlatoonProfile::lastSeenAt) } - fun find(storageId: String): PlatoonProfile? = synchronized(lock) { - ensureInitialized() + fun find(storageId: String): PlatoonProfile? = initialized { readLocked(storageId)?.takeUnless(PlatoonProfile::legacy) } @@ -162,23 +158,27 @@ internal class PlatoonProfileRegistry(context: Context) { client: PlatoonClient, region: GameServerRegion, platoonId: Long, - ): PlatoonProfile? = synchronized(lock) { - ensureInitialized() - readAllLocked().firstOrNull { - !it.legacy && - it.client == client && - it.serverRegion == region && - it.platoonId == platoonId - } + ): PlatoonProfile? = initialized { profiles -> + profiles.firstOrNull { it.hasIdentity(client, region, platoonId) } } fun findByClientAndPlatoonId( client: PlatoonClient, platoonId: Long, - ): List = synchronized(lock) { - ensureInitialized() - readAllLocked().filter { - !it.legacy && it.client == client && it.platoonId == platoonId + ): List = initialized { profiles -> + profiles.filter { it.client == client && it.platoonId == platoonId } + } + + /** + * Global lock order is PlatoonRepository maintenance lock -> registry [lock]. Finishing a + * pending deletion takes the exclusive maintenance lock, so it runs before [lock] is held; + * restores already hold the maintenance lock when they update registry metadata. + */ + private inline fun initialized(block: (List) -> T): T { + PlatoonProfileAdministration.recoverPending(appContext) + return synchronized(lock) { + retireLegacyMetadataLocked() + block(readAllLocked().filterNot(PlatoonProfile::legacy)) } } @@ -217,12 +217,7 @@ internal class PlatoonProfileRegistry(context: Context) { require(region != GameServerRegion.MANUAL) { "A server region is required" } require(data.platoonId != 0u && data.platoonName.isNotBlank()) val platoonId = data.platoonId.toLong() - val existing = readAllLocked().firstOrNull { - !it.legacy && - it.client == client && - it.serverRegion == region && - it.platoonId == platoonId - } + val existing = readAllLocked().firstOrNull { it.hasIdentity(client, region, platoonId) } val storageId = existing?.storageId ?: allocateStorageIdLocked(client, region, platoonId) require(readLocked(storageId) != null || readAllLocked().size < MAX_PROFILES) { "Too many Platoon profiles are already registered" @@ -268,12 +263,9 @@ internal class PlatoonProfileRegistry(context: Context) { "The server region does not belong to this client" } require(platoonId in 1L..UInt.MAX_VALUE.toLong()) { "Platoon ID is invalid" } - require(readAllLocked().none { - !it.legacy && - it.client == client && - it.serverRegion == region && - it.platoonId == platoonId - }) { "That client/server Platoon profile already exists" } + require(readAllLocked().none { it.hasIdentity(client, region, platoonId) }) { + "That client/server Platoon profile already exists" + } require(readAllLocked().size < MAX_PROFILES) { "Too many Platoon profiles are already registered" } @@ -328,10 +320,7 @@ internal class PlatoonProfileRegistry(context: Context) { require( readAllLocked().none { it.storageId != storageId && - !it.legacy && - it.client == current.client && - it.serverRegion == region && - it.platoonId == current.platoonId + it.hasIdentity(current.client, region, current.platoonId) }, ) { "That client/server Platoon profile already exists" } val updated = current.copy(serverRegion = region) @@ -554,11 +543,7 @@ internal class PlatoonProfileRegistry(context: Context) { require( profiles.none { it.storageId != profile.storageId && - !it.legacy && - !profile.legacy && - it.client == profile.client && - it.serverRegion == profile.serverRegion && - it.platoonId == profile.platoonId + it.hasIdentity(profile.client, profile.serverRegion, profile.platoonId) }, ) { "Backup Platoon identity already belongs to another storage scope" } } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt index e716e6a..ccfd8b4 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt @@ -82,6 +82,9 @@ internal class PlatoonProfileAdministration(context: Context) { private const val KEY_PENDING = "pending_storage_ids" fun recoverPending(context: Context) { + // A thread already reading a repository cannot take the exclusive gate; the + // ReentrantReadWriteLock cannot upgrade. The next registry access finishes the work. + if (PlatoonRepository.holdsSharedDatabaseAccess()) return val appContext = context.applicationContext pending(appContext).forEach { storageId -> if (PlatoonProfileIdentity.isValidStorageId(storageId) && 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 affb54e..1bbd386 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -526,9 +526,17 @@ internal class PlatoonRepository( private const val MAX_REVISION_NOTE_BYTES = 16 * 1_024 private const val MAX_REVISION_PRIVATE_NOTE_BYTES = 2 * 1_024 private const val TAG = "GF2PlatoonRepository" + /* + * Global lock order; acquire left to right and never in reverse: + * CsvImportCheckpointManager scope state -> maintenanceLock -> PlatoonDatabase monitor -> + * PlatoonProfileRegistry lock -> settings preference locks. databaseLock only guards the + * helper cache; it is taken after the maintenance lock and only closes evicted helpers. + */ private val databaseLock = Any() private val maintenanceLock = ReentrantReadWriteLock(true) + internal fun holdsSharedDatabaseAccess(): Boolean = maintenanceLock.readHoldCount > 0 + private val databaseInstances = mutableMapOf() private fun database(context: Context, scope: PlatoonStorageScope): PlatoonDatabase = From 8b0ae8558850468d07de2603d546b2bed17e9294 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:03:06 +0900 Subject: [PATCH 010/125] refactor: model capture status as typed atomic snapshots Replace stringly-typed capture messages with CapturePhase, localized CaptureNotice entries, and one immutable CaptureSnapshot swapped by CAS, so UI and service updates and guided-payload marks can no longer tear or lose writes. Status and pending-Platoon candidates now notify main-looper listeners; read() and readGuidedProgress() remain as deprecated delegates until the UI migrates. The pending store keeps a running payload total instead of summing on every offer. --- .../dev/gf2log/app/capture/CaptureStatus.kt | 207 +++++++++++++++--- .../gf2log/app/capture/CaptureVpnService.kt | 125 ++++++----- .../java/dev/gf2log/app/capture/MainThread.kt | 29 +++ .../capture/PendingPlatoonAdmissionStore.kt | 46 +++- .../app/capture/PlatoonCaptureSession.kt | 11 +- .../main/res/values-ko/strings_capture.xml | 45 ++++ app/src/main/res/values/strings_capture.xml | 45 ++++ .../gf2log/app/capture/CaptureStatusTest.kt | 99 +++++++-- .../PendingPlatoonAdmissionStoreTest.kt | 64 +++++- 9 files changed, 553 insertions(+), 118 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/capture/MainThread.kt create mode 100644 app/src/main/res/values-ko/strings_capture.xml create mode 100644 app/src/main/res/values/strings_capture.xml 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 63bb654..772f1b9 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt @@ -1,62 +1,201 @@ package dev.gf2log.app.capture +import android.content.Context +import androidx.annotation.StringRes +import dev.gf2log.app.R +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +enum class CapturePhase { STOPPED, STARTING, RUNNING } + +/** Every user-visible capture status message, localized through [messageRes]. */ +enum class CaptureNotice(@StringRes val messageRes: Int, val severity: Severity) { + PREPARING(R.string.capture_notice_preparing, Severity.INFO), + ALREADY_RUNNING(R.string.capture_notice_already_running, Severity.INFO), + /** Args: comma-separated captured package IDs. */ + CAPTURING(R.string.capture_notice_capturing, Severity.INFO), + NATIVE_UNAVAILABLE(R.string.capture_notice_native_unavailable, Severity.ERROR), + HISTORY_IMPORT_FAILED(R.string.capture_notice_history_import_failed, Severity.ERROR), + NO_SUPPORTED_CLIENT(R.string.capture_notice_no_supported_client, Severity.ERROR), + /** Args: failure reason. */ + VPN_ESTABLISH_FAILED(R.string.capture_notice_vpn_establish_failed, Severity.ERROR), + VPN_PERMISSION_REVOKED(R.string.capture_notice_vpn_permission_revoked, Severity.ERROR), + 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. */ + 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), + /** Args: exception class name. */ + PROCESSING_FAILED(R.string.capture_notice_processing_failed, Severity.ERROR), + /** Args: total dropped chunks. */ + PARSER_OVERLOADED(R.string.capture_notice_parser_overloaded, Severity.WARNING), + FLOW_BUFFER_OVERFLOW(R.string.capture_notice_flow_buffer_overflow, Severity.WARNING), + IDENTIFIED_FLOW_BUFFER_OVERFLOW( + R.string.capture_notice_identified_flow_buffer_overflow, + Severity.WARNING, + ), + CLIENT_UNVERIFIED(R.string.capture_notice_client_unverified, Severity.WARNING), + IDENTITY_CHANGED(R.string.capture_notice_identity_changed, Severity.WARNING), + PENDING_IDENTITY_CHANGED(R.string.capture_notice_pending_identity_changed, Severity.WARNING), + PROFILE_UPDATE_FAILED(R.string.capture_notice_profile_update_failed, Severity.ERROR), + /** Args: Platoon name, Platoon ID, client name. */ + PLATOON_DETECTED(R.string.capture_notice_platoon_detected, Severity.INFO), + PENDING_DETECTED(R.string.capture_notice_pending_detected, Severity.INFO), + PENDING_QUEUE_FULL(R.string.capture_notice_pending_queue_full, Severity.WARNING), + PENDING_OVERFLOW(R.string.capture_notice_pending_overflow, Severity.WARNING), + ADMISSION_IN_PROGRESS(R.string.capture_notice_admission_in_progress, Severity.INFO), + ADMISSION_INVALID(R.string.capture_notice_admission_invalid, Severity.ERROR), + ADMISSION_REGION_MISMATCH(R.string.capture_notice_admission_region_mismatch, Severity.ERROR), + ADMISSION_LIMIT(R.string.capture_notice_admission_limit, Severity.WARNING), + ADMISSION_PROFILE_FAILED(R.string.capture_notice_admission_profile_failed, Severity.ERROR), + ADMISSION_REPLAY_FAILED(R.string.capture_notice_admission_replay_failed, Severity.ERROR), + /** Args: Platoon name, Platoon ID, client name. */ + PLATOON_CONFIRMED(R.string.capture_notice_platoon_confirmed, Severity.INFO), + PENDING_DISCARDED(R.string.capture_notice_pending_discarded, Severity.INFO), + /** Args: member count, Platoon name. */ + ROSTER_SAVED(R.string.capture_notice_roster_saved, Severity.INFO), + ROSTER_SAVE_FAILED(R.string.capture_notice_roster_save_failed, Severity.ERROR), + /** Args: Platoon name, joined count, left count. */ + ROSTER_UPDATED(R.string.capture_notice_roster_updated, Severity.INFO), + /** Args: Platoon name. */ + ROSTER_RECOVERED(R.string.capture_notice_roster_recovered, Severity.WARNING), + ACTIVITY_SAVE_FAILED(R.string.capture_notice_activity_save_failed, Severity.ERROR), + UPDATES_SAVE_FAILED(R.string.capture_notice_updates_save_failed, Severity.ERROR), + HISTORY_SAVE_FAILED(R.string.capture_notice_history_save_failed, Severity.ERROR), + ; + + enum class Severity { INFO, WARNING, ERROR } +} + +data class CaptureNoticeEvent( + val notice: CaptureNotice, + val args: List = emptyList(), + val atMillis: Long, +) + +fun CaptureNoticeEvent.format(context: Context): String = + context.getString(notice.messageRes, *args.toTypedArray()) + +data class CaptureSnapshot( + val phase: CapturePhase, + val notice: CaptureNoticeEvent?, + val capturedTargets: List, + val guided: CaptureStatus.GuidedProgress?, +) + +/** Process-wide capture state, swapped atomically as one immutable [CaptureSnapshot]. */ object CaptureStatus { - private const val DEFAULT_MESSAGE = "Capture is stopped" + private val state = AtomicReference( + CaptureSnapshot(CapturePhase.STOPPED, notice = null, capturedTargets = emptyList(), guided = null), + ) + private val listeners = CopyOnWriteArraySet<(CaptureSnapshot) -> Unit>() + private val dispatchPending = AtomicBoolean(false) @Volatile - private var message = DEFAULT_MESSAGE + private var legacyContext: Context? = null - @Volatile - var isRunning: Boolean = false - private set + fun snapshot(): CaptureSnapshot = state.get() - @Volatile - var isStarting: Boolean = false - private set + val isRunning: Boolean + get() = snapshot().phase == CapturePhase.RUNNING - @Volatile - private var guidedProgress: GuidedProgress? = null + val isStarting: Boolean + get() = snapshot().phase == CapturePhase.STARTING - fun read(): String = message + /** [listener] runs on the main looper: once immediately, then after every change. */ + fun addListener(listener: (CaptureSnapshot) -> Unit) { + listeners += listener + MainThread.runOrPost { if (listener in listeners) listener(snapshot()) } + } + + fun removeListener(listener: (CaptureSnapshot) -> Unit) { + listeners -= listener + } + + fun markStarting() = update { + it.copy(phase = CapturePhase.STARTING, notice = event(CaptureNotice.PREPARING)) + } + + fun markStopped() = markStopped(notice = null) - fun readGuidedProgress(): GuidedProgress? = guidedProgress + internal fun markStopped(notice: CaptureNotice?, vararg args: String) = update { + it.copy( + phase = CapturePhase.STOPPED, + notice = notice?.let { value -> event(value, args) }, + capturedTargets = emptyList(), + ) + } + + internal fun markRunning(targets: List) = update { + it.copy( + phase = CapturePhase.RUNNING, + notice = event(CaptureNotice.CAPTURING, arrayOf(targets.joinToString())), + capturedTargets = targets.toList(), + ) + } - fun update(message: String) { - this.message = message + internal fun report(notice: CaptureNotice, vararg args: String) = update { + it.copy(notice = event(notice, args)) } - fun markRunning(message: String, guided: Boolean = guidedProgress != null) { - isRunning = true - isStarting = false - this.message = message - if (guided && guidedProgress == null) guidedProgress = GuidedProgress() + internal fun beginSession(guided: Boolean) = update { + it.copy(guided = GuidedProgress().takeIf { guided }) } - fun markStarting(message: String = "Preparing capture") { - isRunning = false - isStarting = true - this.message = message + internal fun markUsefulPayload(payloadType: Int) = update { current -> + val guided = current.guided ?: return@update current + if (payloadType in guided.capturedPayloadTypes) return@update current + current.copy(guided = guided.copy(capturedPayloadTypes = guided.capturedPayloadTypes + payloadType)) } - fun markStopped(message: String = DEFAULT_MESSAGE) { - isRunning = false - isStarting = false - this.message = message + /** Lets the deprecated [read] localize notices until the UI moves to [snapshot]. */ + internal fun attachLegacyContext(context: Context) { + legacyContext = context.applicationContext } - fun beginSession(guided: Boolean) { - guidedProgress = GuidedProgress().takeIf { guided } + @Deprecated("Use snapshot() and CaptureNoticeEvent.format()") + fun read(): String { + val current = snapshot() + val notice = current.notice + ?: return if (current.phase == CapturePhase.STARTING) LEGACY_PREPARING else LEGACY_STOPPED + return when (notice.notice) { + CaptureNotice.PREPARING -> LEGACY_PREPARING + CaptureNotice.CAPTURING -> "Capturing only " + notice.args.joinToString() + else -> legacyContext?.let(notice::format) ?: notice.notice.name + } } - fun markUsefulPayload(payloadType: Int) { - val current = guidedProgress ?: return - guidedProgress = current.copy(capturedPayloadTypes = current.capturedPayloadTypes + payloadType) + @Deprecated("Use snapshot().guided") + fun readGuidedProgress(): GuidedProgress? = snapshot().guided + + private inline fun update(transform: (CaptureSnapshot) -> CaptureSnapshot) { + while (true) { + val previous = state.get() + val next = transform(previous) + if (next === previous) return + if (state.compareAndSet(previous, next)) break + } + notifyListeners() } - fun clearSession() { - guidedProgress = null + private fun notifyListeners() { + if (listeners.isEmpty() || !dispatchPending.compareAndSet(false, true)) return + MainThread.post { + dispatchPending.set(false) + val current = snapshot() + listeners.forEach { it(current) } + } } + private fun event(notice: CaptureNotice, args: Array = emptyArray()) = + CaptureNoticeEvent(notice, args.toList(), System.currentTimeMillis()) + + private const val LEGACY_STOPPED = "Capture is stopped" + private const val LEGACY_PREPARING = "Preparing capture" + data class GuidedProgress( val capturedPayloadTypes: Set = emptySet(), ) 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 3f0b002..46a2754 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -79,7 +79,7 @@ class CaptureVpnService : VpnService() { .minus(captured) .map(PayloadCatalog::tag) .joinToString() - stopCapture("Captured Platoon roster; missing $missing after the navigation period") + stopCapture(CaptureNotice.CAPTURE_ONCE_PARTIAL, missing) } } private val migrationExecutor = Executors.newSingleThreadExecutor { runnable -> @@ -96,6 +96,7 @@ class CaptureVpnService : VpnService() { payloadHistoryPreferences = PayloadHistoryPreferences(this) capturePreferences = CapturePreferences(this) diagnosticsStore = CaptureDiagnosticsStore(this) + CaptureStatus.attachLegacyContext(this) createNotificationChannel() } @@ -113,14 +114,14 @@ class CaptureVpnService : VpnService() { intent.getStringExtra(EXTRA_SERVER_REGION), ) if (token.isNullOrBlank() || region == GameServerRegion.MANUAL) { - CaptureStatus.update("Unable to confirm the pending Platoon") + CaptureStatus.report(CaptureNotice.ADMISSION_INVALID) stopSelf(startId) } else if (!submitParserTask { confirmPendingPlatoon(token, region) if (tunnel == null) stopSelf(startId) } ) { - CaptureStatus.update("Unable to queue the pending Platoon confirmation") + CaptureStatus.report(CaptureNotice.PARSER_OVERLOADED) stopSelf(startId) } } @@ -132,7 +133,7 @@ class CaptureVpnService : VpnService() { if (tunnel == null) stopSelf(startId) } ) { - CaptureStatus.update("Unable to queue the pending Platoon discard") + CaptureStatus.report(CaptureNotice.PARSER_OVERLOADED) stopSelf(startId) } } else { @@ -160,19 +161,19 @@ class CaptureVpnService : VpnService() { private fun startCapture() { if (tunnel != null) { - CaptureStatus.markRunning("Capture is already running") + CaptureStatus.report(CaptureNotice.ALREADY_RUNNING) updateNotification("Capturing selected game traffic") return } if (captureStartPending) { - CaptureStatus.update("Preparing capture") + CaptureStatus.report(CaptureNotice.PREPARING) return } startInForeground("Preparing capture") - CaptureStatus.update("Preparing capture") + CaptureStatus.report(CaptureNotice.PREPARING) if (!NativeCaptureBridge.isAvailable) { - failStart("Protocol parser ready; native forwarding core is not integrated yet") + failStart(CaptureNotice.NATIVE_UNAVAILABLE) return } @@ -189,7 +190,7 @@ class CaptureVpnService : VpnService() { captureStartPending = false migration.fold( onSuccess = { startTunnel() }, - onFailure = { failStart("Unable to import previous Platoon history") }, + onFailure = { failStart(CaptureNotice.HISTORY_IMPORT_FAILED) }, ) } } @@ -214,19 +215,22 @@ class CaptureVpnService : VpnService() { } } if (installedTargets.isEmpty()) { - failStart("Install a supported HaoPlay or Darkwinter client first") + failStart(CaptureNotice.NO_SUPPORTED_CLIENT) return } val descriptor = try { builder.establish() } catch (error: Exception) { - failStart("Unable to establish VPN: ${error.message ?: error.javaClass.simpleName}") + failStart( + CaptureNotice.VPN_ESTABLISH_FAILED, + error.message ?: error.javaClass.simpleName, + ) return } if (descriptor == null) { - failStart("VPN permission was revoked") + failStart(CaptureNotice.VPN_PERMISSION_REVOKED) return } tunnel = descriptor @@ -250,7 +254,6 @@ class CaptureVpnService : VpnService() { mainHandler.removeCallbacks(captureOnceGraceStop) sessionStartedAt = Instant.now() - CaptureStatus.markRunning("Starting native capture") val started = try { NativeCaptureBridge.start( descriptor.fd, @@ -299,11 +302,11 @@ class CaptureVpnService : VpnService() { false } if (!started) { - failStart("Native forwarding core failed to start") + failStart(CaptureNotice.NATIVE_START_FAILED) return } - CaptureStatus.markRunning("Capturing only ${installedTargets.joinToString()}") + CaptureStatus.markRunning(installedTargets) updateNotification("Capturing selected game traffic") } @@ -385,13 +388,13 @@ class CaptureVpnService : VpnService() { private fun submitParserTask(task: () -> Unit): Boolean = try { parserExecutor.execute { runCatching(task).onFailure { - CaptureStatus.update("Protocol processing failed: ${it.javaClass.simpleName}") + CaptureStatus.report(CaptureNotice.PROCESSING_FAILED, it.javaClass.simpleName) } } true } catch (_: RejectedExecutionException) { val dropped = droppedParserTaskCount.incrementAndGet() - CaptureStatus.update("Parser overloaded; dropped $dropped queued chunks") + CaptureStatus.report(CaptureNotice.PARSER_OVERLOADED, dropped.toString()) false } @@ -437,13 +440,13 @@ class CaptureVpnService : VpnService() { ) { if (pendingFlowPayloads.isRejected(flowId)) { quarantineFlow(flowId) - CaptureStatus.update("Discarded an identified flow whose pre-identity buffer overflowed") + CaptureStatus.report(CaptureNotice.IDENTIFIED_FLOW_BUFFER_OVERFLOW) return } val ownerPackage = metadata?.ownerPackage if (ownerPackage !in SupportedGamePackages.all) { quarantineFlow(flowId) - CaptureStatus.update("Detected a Platoon profile, but its game client could not be verified") + CaptureStatus.report(CaptureNotice.CLIENT_UNVERIFIED) return } val verifiedOwnerPackage = requireNotNull(ownerPackage) @@ -458,7 +461,7 @@ class CaptureVpnService : VpnService() { } discardPendingPlatoon(token) quarantineFlow(flowId) - CaptureStatus.update("Discarded a flow whose pending Platoon identity changed") + CaptureStatus.report(CaptureNotice.PENDING_IDENTITY_CHANGED) return } val knownProfiles = profileRegistry.findByClientAndPlatoonId( @@ -480,14 +483,14 @@ class CaptureVpnService : VpnService() { val current = flowSessions[flowId] if (current != null && current.profile.storageId != known.storageId) { quarantineFlow(flowId) - CaptureStatus.update("Discarded a flow whose Platoon identity changed") + CaptureStatus.report(CaptureNotice.IDENTITY_CHANGED) return } val profile = runCatching { profileRegistry.updateObserved(known.storageId, data) }.getOrElse { quarantineFlow(flowId) - CaptureStatus.update("Unable to isolate the detected Platoon") + CaptureStatus.report(CaptureNotice.PROFILE_UPDATE_FAILED) return } if (current?.profile?.storageId == profile.storageId) { @@ -501,9 +504,11 @@ class CaptureVpnService : VpnService() { pendingFlowPayloads.take(flowId).forEach { pending -> routeConfirmedPayload(session, pending) } - CaptureStatus.update( - "Detected ${profile.platoonName.take(40)} (${profile.platoonId}) via " + - profile.client.displayName, + CaptureStatus.report( + CaptureNotice.PLATOON_DETECTED, + profile.platoonName.take(40), + profile.platoonId.toString(), + profile.client.displayName, ) markRequiredPayloadCaptured( profile.storageId, @@ -516,7 +521,7 @@ class CaptureVpnService : VpnService() { when (val result = PendingPlatoonAdmissionStore.offer(token, flowId, payload)) { PendingPlatoonAdmissionStore.OfferResult.Accepted -> Unit PendingPlatoonAdmissionStore.OfferResult.Claimed -> - CaptureStatus.update("Waiting for the pending Platoon confirmation to finish") + CaptureStatus.report(CaptureNotice.ADMISSION_IN_PROGRESS) PendingPlatoonAdmissionStore.OfferResult.Missing -> { pendingAdmissionByFlow.remove(flowId, token) quarantineFlow(flowId) @@ -526,14 +531,14 @@ class CaptureVpnService : VpnService() { pendingAdmissionByFlow.remove(rejectedFlow, token) quarantineFlow(rejectedFlow) } - CaptureStatus.update("Discarded an oversized pending Platoon capture") + CaptureStatus.report(CaptureNotice.PENDING_OVERFLOW) } } return } when (pendingFlowPayloads.offer(flowId, payload)) { BoundedFlowPayloadBuffer.OfferResult.OVERFLOW -> - CaptureStatus.update("Discarded an unidentified Platoon flow that exceeded its buffer") + CaptureStatus.report(CaptureNotice.FLOW_BUFFER_OVERFLOW) BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, BoundedFlowPayloadBuffer.OfferResult.REJECTED, -> Unit @@ -549,7 +554,7 @@ class CaptureVpnService : VpnService() { val token = result.token if (token == null) { result.rejectedFlowIds.forEach(::quarantineFlow) - CaptureStatus.update("Discarded a new Platoon because the confirmation queue is full") + CaptureStatus.report(CaptureNotice.PENDING_QUEUE_FULL) return } pendingAdmissionByFlow[flowId] = token @@ -561,7 +566,7 @@ class CaptureVpnService : VpnService() { pendingAdmissionByFlow.remove(rejected, token) quarantineFlow(rejected) } - CaptureStatus.update("Discarded an oversized pending Platoon capture") + CaptureStatus.report(CaptureNotice.PENDING_OVERFLOW) return } PendingPlatoonAdmissionStore.OfferResult.Claimed, @@ -573,7 +578,7 @@ class CaptureVpnService : VpnService() { } } } - CaptureStatus.update("New Platoon detected; return to GF2logger and choose its server") + CaptureStatus.report(CaptureNotice.PENDING_DETECTED) } private fun confirmPendingPlatoon(token: String, region: GameServerRegion) { @@ -581,7 +586,7 @@ class CaptureVpnService : VpnService() { val ownerPackage = claim.summary.ownerPackage if (region !in clientServerRegions.allowed(ownerPackage)) { PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.update("The selected server does not belong to the verified client") + CaptureStatus.report(CaptureNotice.ADMISSION_REGION_MISMATCH) return } val client = requireNotNull(PlatoonClient.fromPackage(ownerPackage)) @@ -592,7 +597,7 @@ class CaptureVpnService : VpnService() { ) if (!profileAdmissionGate.canAdmit(ownerPackage, existing != null)) { PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.update("Start a new capture before adding another Platoon for this client") + CaptureStatus.report(CaptureNotice.ADMISSION_LIMIT) return } val previousRegion = clientServerRegions.stored(ownerPackage) @@ -612,7 +617,7 @@ class CaptureVpnService : VpnService() { } }.onFailure(error::addSuppressed) PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.update("Unable to create the confirmed Platoon profile") + CaptureStatus.report(CaptureNotice.ADMISSION_PROFILE_FAILED) return } if (existing == null) profileAdmissionGate.markAdmitted(ownerPackage) @@ -647,7 +652,7 @@ class CaptureVpnService : VpnService() { runCatching(session::close) } PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.update("Unable to apply the confirmed Platoon packets") + CaptureStatus.report(CaptureNotice.ADMISSION_REPLAY_FAILED) return } claim.endedFlowIds.forEach { flowId -> @@ -659,9 +664,11 @@ class CaptureVpnService : VpnService() { PendingPlatoonAdmissionStore.complete(token).forEach { flowId -> pendingAdmissionByFlow.remove(flowId, token) } - CaptureStatus.update( - "Confirmed ${profile.platoonName.take(40)} (${profile.platoonId}) via " + - profile.client.displayName, + CaptureStatus.report( + CaptureNotice.PLATOON_CONFIRMED, + profile.platoonName.take(40), + profile.platoonId.toString(), + profile.client.displayName, ) } @@ -672,7 +679,7 @@ class CaptureVpnService : VpnService() { quarantineFlow(flowId) } } - CaptureStatus.update("Discarded the unconfirmed Platoon packets") + CaptureStatus.report(CaptureNotice.PENDING_DISCARDED) } private fun replaceFlowSession( @@ -684,7 +691,6 @@ class CaptureVpnService : VpnService() { context = this, profile = profile, onRosterCaptured = ::markRosterCaptured, - onStatus = CaptureStatus::update, ).also { flowSessions[flowId] = it } } @@ -708,7 +714,7 @@ class CaptureVpnService : VpnService() { Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, ) } - }?.onFailure { CaptureStatus.update("Unable to update Platoon activity history") } + }?.onFailure { CaptureStatus.report(CaptureNotice.ACTIVITY_SAVE_FAILED) } routed.updates?.onSuccess { accepted -> if (accepted) { markRequiredPayloadCaptured( @@ -716,14 +722,16 @@ class CaptureVpnService : VpnService() { Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, ) } - }?.onFailure { CaptureStatus.update("Unable to update exact Platoon history") } + }?.onFailure { CaptureStatus.report(CaptureNotice.UPDATES_SAVE_FAILED) } routed.members.onSuccess { saved -> if (saved != null) { - CaptureStatus.update( - "Saved ${saved.rowCount} members for ${session.profile.platoonName}", + CaptureStatus.report( + CaptureNotice.ROSTER_SAVED, + saved.rowCount.toString(), + session.profile.platoonName, ) } - }.onFailure { CaptureStatus.update("Unable to save Platoon CSV") } + }.onFailure { CaptureStatus.report(CaptureNotice.ROSTER_SAVE_FAILED) } } private fun routeConfirmedPayload( @@ -750,7 +758,7 @@ class CaptureVpnService : VpnService() { ) } runCatching { historyStore.save(payload) } - .onFailure { CaptureStatus.update("Unable to save parsed-packet history") } + .onFailure { CaptureStatus.report(CaptureNotice.HISTORY_SAVE_FAILED) } } private fun closeFlowSession(flowId: Long) { @@ -775,7 +783,7 @@ class CaptureVpnService : VpnService() { CaptureStatus.isRunning && captureChecklist.targetComplete() ) { - stopCapture("Captured Platoon roster, activity, and updates") + stopCapture(CaptureNotice.CAPTURE_ONCE_COMPLETE) } } } @@ -802,10 +810,13 @@ class CaptureVpnService : VpnService() { val bucket = observed / TRAFFIC_REPORT_BYTES val previousBucket = reportedTrafficBucket.get() if (bucket > previousBucket && reportedTrafficBucket.compareAndSet(previousBucket, bucket)) { - CaptureStatus.update( - "Forwarded ${observed / 1024} KiB; inspected ${inspected / 1024} KiB; " + - "decoded ${decodedPayloadCount.get()} payloads; " + - "warnings ${parseWarningCount.get()}; dropped ${droppedParserTaskCount.get()}", + CaptureStatus.report( + CaptureNotice.TRAFFIC, + (observed / 1024).toString(), + (inspected / 1024).toString(), + decodedPayloadCount.get().toString(), + parseWarningCount.get().toString(), + droppedParserTaskCount.get().toString(), ) } } @@ -820,24 +831,24 @@ class CaptureVpnService : VpnService() { flowMetadata.clear() closeAllFlowSessions() pendingFlowPayloads.clear() - CaptureStatus.markStopped("Capture stopped unexpectedly; press Prepare capture to retry") + CaptureStatus.markStopped(CaptureNotice.STOPPED_UNEXPECTEDLY) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } } - private fun failStart(message: String) { + private fun failStart(notice: CaptureNotice, vararg args: String) { captureStartPending = false releaseCaptureResources() - CaptureStatus.markStopped(message) + CaptureStatus.markStopped(notice, *args) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } - private fun stopCapture(message: String = "") { + private fun stopCapture(notice: CaptureNotice? = null, vararg args: String) { captureStartPending = false releaseCaptureResources() - if (message.isBlank()) CaptureStatus.markStopped() else CaptureStatus.markStopped(message) + CaptureStatus.markStopped(notice, *args) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } @@ -873,7 +884,7 @@ class CaptureVpnService : VpnService() { unknownPayloads = unknownPayloadCounts.entries .sortedBy { it.key } .joinToString { "${it.key}:${it.value.get()}" }, - finalStatus = CaptureStatus.read(), + finalStatus = CaptureStatus.snapshot().notice?.format(this).orEmpty(), ), ) sessionStartedAt = null diff --git a/app/src/main/java/dev/gf2log/app/capture/MainThread.kt b/app/src/main/java/dev/gf2log/app/capture/MainThread.kt new file mode 100644 index 0000000..4e36241 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/MainThread.kt @@ -0,0 +1,29 @@ +package dev.gf2log.app.capture + +import android.os.Handler +import android.os.Looper +import androidx.annotation.VisibleForTesting + +/** Delivers capture listener callbacks on the main looper. */ +internal object MainThread { + /** JVM unit tests have no Android looper; they install a direct executor instead. */ + @VisibleForTesting + @Volatile + var testExecutor: ((() -> Unit) -> Unit)? = null + + private val handler by lazy { Handler(Looper.getMainLooper()) } + + fun post(block: () -> Unit) { + testExecutor?.invoke(block) ?: handler.post(block) + } + + /** Runs [block] synchronously when already on the main thread, otherwise posts it. */ + fun runOrPost(block: () -> Unit) { + val executor = testExecutor + when { + executor != null -> executor(block) + Looper.myLooper() == Looper.getMainLooper() -> block() + else -> handler.post(block) + } + } +} 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 f0cedaf..88dfc80 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt @@ -1,10 +1,12 @@ package dev.gf2log.app.capture +import androidx.annotation.VisibleForTesting import dev.gf2log.app.SupportedGamePackages import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonProfileData import java.time.Instant import java.util.UUID +import java.util.concurrent.CopyOnWriteArraySet /** * Holds unconfirmed Platoon evidence only in process memory. @@ -60,6 +62,21 @@ internal object PendingPlatoonAdmissionStore { private val candidates = linkedMapOf() private val tokenByIdentity = mutableMapOf() + private var totalPayloads = 0 + private val listeners = CopyOnWriteArraySet<(List) -> Unit>() + + /** + * [listener] receives the visible candidates on the main looper: once immediately, then + * whenever a candidate appears, changes identity, is claimed or released, or is removed. + */ + fun addListener(listener: (List) -> Unit) { + listeners += listener + MainThread.runOrPost { if (listener in listeners) listener(summaries()) } + } + + fun removeListener(listener: (List) -> Unit) { + listeners -= listener + } @Synchronized fun begin( @@ -74,7 +91,10 @@ internal object PendingPlatoonAdmissionStore { tokenByIdentity[key]?.let { token -> val existing = candidates[token] if (existing != null && !existing.claimed) { - existing.profile = profile + if (existing.profile != profile) { + existing.profile = profile + notifyChanged() + } existing.flowIds += flowId return BeginResult(token) } @@ -92,6 +112,7 @@ internal object PendingPlatoonAdmissionStore { flowIds = linkedSetOf(flowId), ) tokenByIdentity[key] = token + notifyChanged() return BeginResult(token) } @@ -101,12 +122,13 @@ internal object PendingPlatoonAdmissionStore { if (candidate.claimed) return OfferResult.Claimed if ( candidate.payloads.size >= MAX_PAYLOADS_PER_CANDIDATE || - candidates.values.sumOf { it.payloads.size } >= MAX_TOTAL_PAYLOADS + totalPayloads >= MAX_TOTAL_PAYLOADS ) { return OfferResult.Overflow(removeLocked(token)?.flowIds.orEmpty()) } candidate.flowIds += flowId candidate.payloads += BufferedPayload(flowId, payload) + totalPayloads += 1 return OfferResult.Accepted } @@ -136,6 +158,7 @@ internal object PendingPlatoonAdmissionStore { val candidate = candidates[token] ?: return null if (candidate.claimed) return null candidate.claimed = true + notifyChanged() return Claim( summary = candidate.summary(), payloads = candidate.payloads.toList(), @@ -146,7 +169,10 @@ internal object PendingPlatoonAdmissionStore { @Synchronized fun releaseClaim(token: String) { - candidates[token]?.claimed = false + val candidate = candidates[token] ?: return + if (!candidate.claimed) return + candidate.claimed = false + notifyChanged() } @Synchronized @@ -158,10 +184,13 @@ internal object PendingPlatoonAdmissionStore { @Synchronized fun contains(token: String): Boolean = token in candidates + @VisibleForTesting @Synchronized internal fun clearForTests() { candidates.clear() tokenByIdentity.clear() + totalPayloads = 0 + listeners.clear() } private fun Candidate.summary() = Summary( @@ -175,9 +204,20 @@ internal object PendingPlatoonAdmissionStore { private fun removeLocked(token: String): Candidate? { val removed = candidates.remove(token) ?: return null tokenByIdentity.remove(IdentityKey(removed.ownerPackage, removed.profile.platoonId), token) + totalPayloads -= removed.payloads.size + notifyChanged() return removed } + /** Snapshots are taken on the main thread so listeners never run under this lock. */ + private fun notifyChanged() { + if (listeners.isEmpty()) return + MainThread.post { + val current = summaries() + listeners.forEach { it(current) } + } + } + internal const val MAX_CANDIDATES = 4 internal const val MAX_PAYLOADS_PER_CANDIDATE = 64 internal const val MAX_TOTAL_PAYLOADS = 128 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 df72d3c..0859f5f 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt @@ -12,7 +12,6 @@ internal class PlatoonCaptureSession( context: Context, val profile: PlatoonProfile, private val onRosterCaptured: (String) -> Unit, - private val onStatus: (String) -> Unit, ) : AutoCloseable { private val scope = PlatoonStorageScope(profile.storageId) private val repository = PlatoonRepository(context, scope) @@ -42,9 +41,11 @@ internal class PlatoonCaptureSession( if (directResult.isSuccess) { val result = directResult.getOrThrow() if (!result.duplicate) { - onStatus( - "Updated ${profile.platoonName}: +${result.joined + result.rejoined}, " + - "-${result.left}", + CaptureStatus.report( + CaptureNotice.ROSTER_UPDATED, + profile.platoonName, + (result.joined + result.rejoined).toString(), + result.left.toString(), ) } onRosterCaptured(profile.storageId) @@ -63,7 +64,7 @@ internal class PlatoonCaptureSession( ?: recovery.exceptionOrNull() ?: IllegalStateException("Roster ingestion and recovery both failed") } - onStatus("Recovered ${profile.platoonName} from the completed roster CSV") + CaptureStatus.report(CaptureNotice.ROSTER_RECOVERED, profile.platoonName) onRosterCaptured(profile.storageId) } } diff --git a/app/src/main/res/values-ko/strings_capture.xml b/app/src/main/res/values-ko/strings_capture.xml new file mode 100644 index 0000000..cb575a9 --- /dev/null +++ b/app/src/main/res/values-ko/strings_capture.xml @@ -0,0 +1,45 @@ + + + 캡처 준비 중 + 캡처가 이미 실행 중입니다 + %1$s만 캡처 중 + 이 빌드에서는 네이티브 전달 코어를 사용할 수 없습니다 + 이전 서클 기록을 가져올 수 없습니다 + 지원되는 HaoPlay 또는 Darkwinter 클라이언트를 먼저 설치하세요 + VPN을 설정할 수 없습니다: %1$s + VPN 권한이 취소되었습니다 + 네이티브 전달 코어를 시작하지 못했습니다 + 캡처가 예기치 않게 중지되었습니다. 다시 시도하려면 캡처 준비를 누르세요 + 서클 명단, 활동, 동향을 모두 캡처했습니다 + 서클 명단을 캡처했지만 대기 시간이 지나도록 %1$s 항목이 없습니다 + 전달 %1$s KiB · 검사 %2$s KiB · 디코딩 %3$s건 · 경고 %4$s건 · 누락 %5$s건 + 프로토콜 처리 실패: %1$s + 파서 과부하로 대기 중인 청크 %1$s개를 버렸습니다 + 버퍼 한도를 넘은 미식별 서클 연결을 폐기했습니다 + 식별 전 버퍼가 넘친 연결을 식별 후 폐기했습니다 + 서클 정보를 감지했지만 게임 클라이언트를 확인할 수 없습니다 + 서클 식별 정보가 바뀐 연결을 폐기했습니다 + 확인 대기 중인 서클의 식별 정보가 바뀐 연결을 폐기했습니다 + 감지한 서클을 분리해 저장할 수 없습니다 + %3$s에서 %1$s (%2$s) 서클을 감지했습니다 + 새 서클을 감지했습니다. GF2logger로 돌아가 서버를 선택하세요 + 확인 대기열이 가득 차서 새 서클을 폐기했습니다 + 한도를 초과한 확인 대기 서클 캡처를 폐기했습니다 + 대기 중인 서클 확인이 끝나기를 기다리는 중입니다 + 대기 중인 서클을 확인할 수 없습니다 + 선택한 서버가 확인된 클라이언트에 속하지 않습니다 + 이 클라이언트에 다른 서클을 추가하려면 새 캡처를 시작하세요 + 확인한 서클 프로필을 만들 수 없습니다 + 확인한 서클 패킷을 적용할 수 없습니다 + %3$s의 %1$s (%2$s) 서클을 확인했습니다 + 확인되지 않은 서클 패킷을 폐기했습니다 + %2$s 서클 멤버 %1$s명을 저장했습니다 + 서클 CSV를 저장할 수 없습니다 + %1$s 갱신: +%2$s, -%3$s + 완료된 명단 CSV에서 %1$s 서클을 복구했습니다 + 서클 활동 기록을 갱신할 수 없습니다 + 서클 동향 기록을 갱신할 수 없습니다 + 파싱 패킷 기록을 저장할 수 없습니다 + 캡처 준비 중 + 선택한 게임 트래픽 캡처 중 + diff --git a/app/src/main/res/values/strings_capture.xml b/app/src/main/res/values/strings_capture.xml new file mode 100644 index 0000000..f8daad2 --- /dev/null +++ b/app/src/main/res/values/strings_capture.xml @@ -0,0 +1,45 @@ + + + Preparing capture + Capture is already running + Capturing only %1$s + The native forwarding core is unavailable in this build + Unable to import previous Platoon history + Install a supported HaoPlay or Darkwinter client first + Unable to establish VPN: %1$s + VPN permission was revoked + Native forwarding core failed to start + Capture stopped unexpectedly; press Prepare capture 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 + Protocol processing failed: %1$s + Parser overloaded; dropped %1$s queued chunks + 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 + Discarded a flow whose Platoon identity changed + Discarded a flow whose pending Platoon identity changed + Unable to isolate the detected Platoon + Detected %1$s (%2$s) via %3$s + New Platoon detected; return to GF2logger and choose its server + Discarded a new Platoon because the confirmation queue is full + Discarded an oversized pending Platoon capture + Waiting for the pending Platoon confirmation to finish + Unable to confirm the pending Platoon + The selected server does not belong to the verified client + Start a new capture before adding another Platoon for this client + Unable to create the confirmed Platoon profile + 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 + Unable to save Platoon CSV + Updated %1$s: +%2$s, -%3$s + Recovered %1$s from the completed roster CSV + Unable to update Platoon activity history + Unable to update exact Platoon history + Unable to save parsed-packet history + Preparing capture + Capturing selected game traffic + 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 6f00d6d..fac34aa 100644 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt @@ -1,51 +1,114 @@ 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 import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit class CaptureStatusTest { - @Test - fun tracksOnlyTheCurrentApplicationProcess() { + @Before + fun setUp() { + MainThread.testExecutor = { it() } CaptureStatus.markStopped() - assertFalse(CaptureStatus.isRunning) - assertFalse(CaptureStatus.isStarting) - assertEquals("Capture is stopped", CaptureStatus.read()) + CaptureStatus.beginSession(guided = false) + } + + @After + fun tearDown() { + MainThread.testExecutor = null + } + + @Test + fun tracksPhaseNoticeAndTargetsAsOneSnapshot() { + assertEquals(CapturePhase.STOPPED, CaptureStatus.snapshot().phase) + assertNull(CaptureStatus.snapshot().notice) CaptureStatus.markStarting() - assertFalse(CaptureStatus.isRunning) assertTrue(CaptureStatus.isStarting) - assertEquals("Preparing capture", CaptureStatus.read()) + assertEquals(CaptureNotice.PREPARING, CaptureStatus.snapshot().notice?.notice) - CaptureStatus.markRunning("Capturing selected package") - CaptureStatus.update("Decoded Platoon members") + CaptureStatus.markRunning(listOf("pkg.one", "pkg.two")) + CaptureStatus.report(CaptureNotice.ROSTER_SAVED, "12", "Owls") + val running = CaptureStatus.snapshot() assertTrue(CaptureStatus.isRunning) assertFalse(CaptureStatus.isStarting) - assertEquals("Decoded Platoon members", CaptureStatus.read()) + assertEquals(listOf("pkg.one", "pkg.two"), running.capturedTargets) + assertEquals(CaptureNotice.ROSTER_SAVED, running.notice?.notice) + assertEquals(listOf("12", "Owls"), running.notice?.args) - CaptureStatus.markStopped("Capture failed") - assertFalse(CaptureStatus.isRunning) - assertFalse(CaptureStatus.isStarting) - assertEquals("Capture failed", CaptureStatus.read()) + CaptureStatus.markStopped(CaptureNotice.NATIVE_START_FAILED) + val stopped = CaptureStatus.snapshot() + assertEquals(CapturePhase.STOPPED, stopped.phase) + assertEquals(emptyList(), stopped.capturedTargets) + assertEquals(CaptureNotice.NATIVE_START_FAILED, stopped.notice?.notice) } @Test fun exposesImmutableGuidedCaptureProgressUntilTheNextSession() { CaptureStatus.beginSession(guided = true) CaptureStatus.markUsefulPayload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) - val progress = requireNotNull(CaptureStatus.readGuidedProgress()) + val progress = requireNotNull(CaptureStatus.snapshot().guided) assertEquals( setOf(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS), progress.capturedPayloadTypes, ) - CaptureStatus.markStopped("Guided capture complete") - assertEquals(progress, CaptureStatus.readGuidedProgress()) + CaptureStatus.markStopped(CaptureNotice.CAPTURE_ONCE_COMPLETE) + assertEquals(progress, CaptureStatus.snapshot().guided) CaptureStatus.beginSession(guided = false) - assertNull(CaptureStatus.readGuidedProgress()) + assertNull(CaptureStatus.snapshot().guided) + } + + @Test + fun concurrentPayloadMarksAreNeverLost() { + CaptureStatus.beginSession(guided = true) + val types = (1..64).toList() + val pool = Executors.newFixedThreadPool(8) + val done = CountDownLatch(types.size) + types.forEach { type -> + pool.execute { + CaptureStatus.markUsefulPayload(type) + CaptureStatus.report(CaptureNotice.TRAFFIC, "$type", "0", "0", "0", "0") + done.countDown() + } + } + assertTrue(done.await(10, TimeUnit.SECONDS)) + pool.shutdown() + + assertEquals(types.toSet(), CaptureStatus.snapshot().guided?.capturedPayloadTypes) + } + + @Test + fun listenersReceiveTheCurrentSnapshotImmediatelyAndAfterChanges() { + val received = mutableListOf() + val listener: (CaptureSnapshot) -> Unit = { received += it } + + CaptureStatus.addListener(listener) + assertEquals(CapturePhase.STOPPED, received.single().phase) + + CaptureStatus.markRunning(listOf("pkg")) + assertEquals(CapturePhase.RUNNING, received.last().phase) + + CaptureStatus.removeListener(listener) + CaptureStatus.markStopped() + assertEquals(2, received.size) + } + + @Test + @Suppress("DEPRECATION") + fun deprecatedReadKeepsTheLegacySentinels() { + assertEquals("Capture is stopped", CaptureStatus.read()) + CaptureStatus.markStarting() + assertEquals("Preparing capture", CaptureStatus.read()) + CaptureStatus.markRunning(listOf("a", "b")) + assertTrue(CaptureStatus.read().startsWith("Capturing only ")) } } 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 8a7e125..704abf5 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt @@ -14,7 +14,69 @@ import java.time.Instant class PendingPlatoonAdmissionStoreTest { @After - fun tearDown() = PendingPlatoonAdmissionStore.clearForTests() + fun tearDown() { + PendingPlatoonAdmissionStore.clearForTests() + MainThread.testExecutor = null + } + + @Test + fun listenersSeeVisibleCandidatesChangeWithoutPolling() { + MainThread.testExecutor = { it() } + val observed = mutableListOf>() + PendingPlatoonAdmissionStore.addListener { summaries -> + observed += summaries.map { it.profile.platoonName } + } + val token = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(5u, "Owls"), + flowId = 1L, + ).token, + ) + PendingPlatoonAdmissionStore.offer(token, 1L, payload(profile(5u, "Owls"))) + PendingPlatoonAdmissionStore.claim(token) + PendingPlatoonAdmissionStore.releaseClaim(token) + PendingPlatoonAdmissionStore.discard(token) + + assertEquals( + listOf(emptyList(), listOf("Owls"), emptyList(), listOf("Owls"), emptyList()), + observed, + ) + } + + @Test + fun totalPayloadBoundIsSharedAcrossCandidatesAndReleasedOnRemoval() { + val tokens = (1..3).map { index -> + requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(index.toUInt(), "P$index"), + flowId = index.toLong(), + ).token, + ) + } + repeat(PendingPlatoonAdmissionStore.MAX_PAYLOADS_PER_CANDIDATE) { + PendingPlatoonAdmissionStore.offer(tokens[0], 1L, payload(profile(1u, "P1"))) + PendingPlatoonAdmissionStore.offer(tokens[1], 2L, payload(profile(2u, "P2"))) + } + assertTrue( + PendingPlatoonAdmissionStore.offer(tokens[2], 3L, payload(profile(3u, "P3"))) is + PendingPlatoonAdmissionStore.OfferResult.Overflow, + ) + + PendingPlatoonAdmissionStore.discard(tokens[0]) + val retry = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(3u, "P3"), + flowId = 3L, + ).token, + ) + assertEquals( + PendingPlatoonAdmissionStore.OfferResult.Accepted, + PendingPlatoonAdmissionStore.offer(retry, 3L, payload(profile(3u, "P3"))), + ) + } @Test fun candidateIsMemoryOnlyBoundedAndClaimedAtomically() { From c0a851bac8b8171b7f8f76e603b9dd7525c72124 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:03:31 +0900 Subject: [PATCH 011/125] fix: compare restored schemas against an in-memory reference --- .../app/management/PlatoonBackupManager.kt | 17 ++++++----------- .../gf2log/app/management/PlatoonDatabase.kt | 17 +++++++++++++---- 2 files changed, 19 insertions(+), 15 deletions(-) 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 6c48162..33cd148 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -404,18 +404,14 @@ class PlatoonBackupManager internal constructor( } } + /** The reference schema lives in memory, so concurrent restores never share a file. */ private fun validateCurrentSchema(database: SQLiteDatabase) { - appContext.deleteDatabase(SCHEMA_REFERENCE_DATABASE) - try { - PlatoonDatabase(appContext, SCHEMA_REFERENCE_DATABASE, storageScope).use { helper -> - val expected = DatabaseSchemaContract.read(helper.readableDatabase) - val actual = DatabaseSchemaContract.read(database) - require(actual == expected) { - "Full backup database does not match the current Platoon schema" - } + PlatoonDatabase.inMemory(appContext, storageScope).use { helper -> + val expected = DatabaseSchemaContract.read(helper.readableDatabase) + val actual = DatabaseSchemaContract.read(database) + require(actual == expected) { + "Full backup database does not match the current Platoon schema" } - } finally { - appContext.deleteDatabase(SCHEMA_REFERENCE_DATABASE) } } @@ -539,7 +535,6 @@ class PlatoonBackupManager internal constructor( companion object { const val FILE_EXTENSION = "gf2backup" const val MIME_TYPE = "application/vnd.dev.gf2log.backup" - private const val SCHEMA_REFERENCE_DATABASE = "platoon-schema-reference.db" private const val RESTORE_TRANSACTION_DIRECTORY = "platoon-full-restore" private const val RESTORE_STATE_FILE = "state" private const val RESTORE_SETTINGS_FILE = "settings.pre_restore" 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 5772106..6582100 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -12,11 +12,10 @@ import java.time.LocalDate import java.time.ZoneId import java.util.Locale -internal class PlatoonDatabase( +internal class PlatoonDatabase private constructor( context: Context, - databaseName: String = PlatoonProfileRegistry(context).activeScope().databaseName, - private val storageScope: PlatoonStorageScope = - PlatoonStorageScope.fromDatabaseName(databaseName), + private val storageScope: PlatoonStorageScope, + databaseName: String?, ) : SQLiteOpenHelper( context.applicationContext, @@ -24,6 +23,12 @@ internal class PlatoonDatabase( null, PlatoonSchema.CURRENT_VERSION, ) { + constructor( + context: Context, + databaseName: String = PlatoonProfileRegistry(context).activeScope().databaseName, + storageScope: PlatoonStorageScope = PlatoonStorageScope.fromDatabaseName(databaseName), + ) : this(context, storageScope, databaseName) + private val appContext = context.applicationContext override fun onConfigure(db: SQLiteDatabase) { super.onConfigure(db) @@ -4017,6 +4022,10 @@ internal class PlatoonDatabase( ) companion object { + /** A fresh current-schema database that never touches storage, for schema comparison. */ + fun inMemory(context: Context, storageScope: PlatoonStorageScope) = + PlatoonDatabase(context, storageScope, null) + /** * A sparse positive signal: the member triggered a Daily Patrol supply * reward, which proves completion for that member and day. Its absence From 5cbfbd0b9277566dd7e9580b555d8a62b5132e9d Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:03:40 +0900 Subject: [PATCH 012/125] fix: run restore recovery only when restore artifacts exist --- .../PlatoonBackupManagerIntegrationTest.kt | 27 +++++++++++++++ .../app/management/PlatoonBackupManager.kt | 33 ++++++++++++------- .../gf2log/app/management/PlatoonProfile.kt | 4 +++ 3 files changed, 52 insertions(+), 12 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 bbb96e4..1fd70ca 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -479,6 +479,33 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(CsvImportCheckpointManager(context, storageScope).canUndo()) } + @Test + fun constructingRepositoriesAndManagersDoesNotWaitForTheExclusiveGate() { + val held = java.util.concurrent.CountDownLatch(1) + val release = java.util.concurrent.CountDownLatch(1) + val holder = Thread { + PlatoonRepository.withExclusiveDatabase(storageScope) { + held.countDown() + release.await() + } + }.apply { + isDaemon = true + start() + } + held.await() + val executor = java.util.concurrent.Executors.newSingleThreadExecutor() + try { + executor.submit { + PlatoonRepository(context, storageScope) + PlatoonBackupManager(context, storageScope) + }.get(5, java.util.concurrent.TimeUnit.SECONDS) + } finally { + release.countDown() + holder.join() + executor.shutdown() + } + } + @Test fun csvCheckpointRecoversAnInterruptedRestoreIntoTheScopedSettings() { settingsStore.replace(currentSettings()) 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 33cd148..edcbf25 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -643,6 +643,9 @@ class PlatoonBackupManager internal constructor( settingsStore: BackupSettingsStore, scope: PlatoonStorageScope, ) { + // Constructing a repository or manager must not take the exclusive gate (and evict + // the cached helper) unless a durable restore artifact actually needs recovery. + if (!hasPendingRestoreWork(appContext, scope)) return PlatoonRepository.withExclusiveDatabase(scope) { val stateFile = restoreStateFile(appContext, scope) if (!stateFile.isFile) { @@ -781,21 +784,27 @@ class PlatoonBackupManager internal constructor( context: Context, scope: PlatoonStorageScope, ) { - val restoreDirectories = buildList { - add(File(context.cacheDir, "platoon-restore/${scope.storageId}")) - if (scope.isLegacy) add(File(context.cacheDir, "platoon-restore")) - } - restoreDirectories.flatMap { it.listFiles().orEmpty().asList() } - .filter { file -> - file.isDirectory && file.name.startsWith("guild-members.retired-") - } - .forEach { directory -> - check(directory.deleteRecursively()) { - "Unable to remove an obsolete retained CSV restore artifact" - } + legacyRetiredCsvArtifacts(context, scope).forEach { directory -> + check(directory.deleteRecursively()) { + "Unable to remove an obsolete retained CSV restore artifact" } + } } + private fun legacyRetiredCsvArtifacts( + context: Context, + scope: PlatoonStorageScope, + ): List = buildList { + add(File(context.cacheDir, "platoon-restore/${scope.storageId}")) + if (scope.isLegacy) add(File(context.cacheDir, "platoon-restore")) + }.flatMap { it.listFiles().orEmpty().asList() } + .filter { file -> file.isDirectory && file.name.startsWith("guild-members.retired-") } + + private fun hasPendingRestoreWork(context: Context, scope: PlatoonStorageScope): Boolean = + restoreTransactionDirectory(context, scope).exists() || + scope.previousRetainedCsvDirectory(context).exists() || + legacyRetiredCsvArtifacts(context, scope).isNotEmpty() + private fun writeAtomic(file: File, bytes: ByteArray) { file.parentFile?.mkdirs() val atomicFile = AtomicFile(file) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt index 7faadb4..c44b6dc 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt @@ -117,6 +117,10 @@ internal data class PlatoonStorageScope(val storageId: String) { fun retainedCsvDirectory(context: Context): File = File(rootDirectory(context), PlatoonRepository.RETAINED_CSV_DIRECTORY) + /** Retained evidence parked by an uncommitted complete restore. */ + fun previousRetainedCsvDirectory(context: Context): File = + File(rootDirectory(context), "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore") + companion object { private val SCOPED_DATABASE = Regex("platoon-([0-9a-f]{32})\\.db") From 2135e69bb02b6bfd664fd06fac7efd6b2f34a585 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:03:47 +0900 Subject: [PATCH 013/125] fix: share backup staging and validation across restore entry points --- .../app/management/PlatoonBackupManager.kt | 196 ++++++------------ 1 file changed, 64 insertions(+), 132 deletions(-) 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 edcbf25..27dfe55 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -91,41 +91,17 @@ class PlatoonBackupManager internal constructor( } } - fun restore(input: InputStream) { - restoreDirectory.mkdirs() - val stagedDatabase = File(restoreDirectory, "platoon.db.staged") - if (stagedDatabase.exists() && !stagedDatabase.delete()) { - error("Unable to clear a previous staged restore") - } - val staged = validateSelectedBackup { - BackupArchive.stage(input, stagedDatabase) - } - try { - val target = managerFor(staged) - target.manager.restoreStagedPlatoon(stagedDatabase, staged, target) - } finally { - stagedDatabase.delete() - } + fun restore(input: InputStream) = withStagedArchive(input) { stagedDatabase, staged -> + val target = managerFor(staged) + target.manager.restoreStagedPlatoon(stagedDatabase, staged, target) } - // Function Name: restoreCheckpoint - // Description: - // - Restores a same-device, current-schema checkpoint without retiring retained CSV evidence. - // - Used only after the caller removes deterministic files planned by the reverted import. - // Parameters: - // - input: Checkpoint archive created by this application. - // Returns: - // - Unit after validated, crash-aware database replacement. - internal fun restoreCheckpoint(input: InputStream) { - restoreDirectory.mkdirs() - val stagedDatabase = File(restoreDirectory, "platoon.db.staged") - if (stagedDatabase.exists() && !stagedDatabase.delete()) { - error("Unable to clear a previous staged restore") - } - val staged = validateSelectedBackup { - BackupArchive.stage(input, stagedDatabase) - } - try { + /** + * Restores a same-device, current-schema checkpoint without retiring retained CSV evidence. + * Used only after the caller removes deterministic files planned by the reverted import. + */ + internal fun restoreCheckpoint(input: InputStream) = + withStagedArchive(input) { stagedDatabase, staged -> validateSelectedBackup { BackupFormatPolicy.requirePlatoonOnly( staged.formatVersion, @@ -135,23 +111,26 @@ class PlatoonBackupManager internal constructor( validateDatabase(stagedDatabase, requireCurrentSchema = true) } replaceRestoredState(stagedDatabase, restoredSettings = null, retireRetainedCsv = false) - } finally { - stagedDatabase.delete() } + + fun restoreFull(input: InputStream) = withStagedArchive(input) { stagedDatabase, staged -> + val target = managerFor(staged) + target.manager.restoreStagedComplete(stagedDatabase, staged, target) } - fun restoreFull(input: InputStream) { - restoreDirectory.mkdirs() + private inline fun withStagedArchive( + input: InputStream, + restoreStaged: (File, BackupArchive.StagedArchive) -> Unit, + ) { + check(restoreDirectory.mkdirs() || restoreDirectory.isDirectory) { + "Unable to create the restore staging directory" + } val stagedDatabase = File(restoreDirectory, "platoon.db.staged") if (stagedDatabase.exists() && !stagedDatabase.delete()) { error("Unable to clear a previous staged restore") } - val staged = validateSelectedBackup { - BackupArchive.stage(input, stagedDatabase) - } try { - val target = managerFor(staged) - target.manager.restoreStagedComplete(stagedDatabase, staged, target) + restoreStaged(stagedDatabase, stageSelectedArchive(input, stagedDatabase)) } finally { stagedDatabase.delete() } @@ -187,10 +166,7 @@ class PlatoonBackupManager internal constructor( private fun managerFor(staged: BackupArchive.StagedArchive): RestoreTarget { val registry = PlatoonProfileRegistry(appContext) - val restoredProfile = requireNotNull(staged.profile) { - "Unscoped legacy backups are no longer supported" - }.toProfile() - require(!restoredProfile.legacy) { "Unscoped legacy backups are no longer supported" } + val restoredProfile = staged.scopedProfile().toProfile() registry.requireRestoreCapacity(restoredProfile) val scope = PlatoonStorageScope(restoredProfile.storageId) val previousProfile = registry.find(scope.storageId) @@ -244,11 +220,7 @@ class PlatoonBackupManager internal constructor( } private fun requireArchiveMatchesScope(staged: BackupArchive.StagedArchive) { - val archivedProfile = requireNotNull(staged.profile) { - "Unscoped legacy backups are no longer supported" - } - require(!archivedProfile.legacy) { "Unscoped legacy backups are no longer supported" } - require(archivedProfile.storageId == storageScope.storageId) { + require(staged.scopedProfile().storageId == storageScope.storageId) { "Backup belongs to a different Platoon" } } @@ -261,26 +233,6 @@ class PlatoonBackupManager internal constructor( } } - // Function Name: validateSelectedBackup - // Description: - // - Converts expected archive, settings, and SQLite validation failures into a typed error. - // - Leaves provider I/O and local restore-state failures distinct for accurate UI reporting. - // Parameters: - // - validation: Read-only validation operation for the selected backup. - // Returns: - // - Returns the validation result when the selected backup is valid. - private inline fun validateSelectedBackup(validation: () -> T): T = try { - validation() - } catch (error: IllegalArgumentException) { - throw InvalidBackupException(error) - } catch (error: SQLiteException) { - throw InvalidBackupException(error) - } catch (error: ZipException) { - throw InvalidBackupException(error) - } catch (error: EOFException) { - throw InvalidBackupException(error) - } - // Function Name: replaceRestoredState // Description: // - Replaces the validated database and retires target-device roster CSV evidence atomically. @@ -297,14 +249,8 @@ class PlatoonBackupManager internal constructor( retireRetainedCsv: Boolean = true, restoreTarget: RestoreTarget? = null, ) { - val retainedCsvDirectory = File( - storageScope.rootDirectory(appContext), - PlatoonRepository.RETAINED_CSV_DIRECTORY, - ) - val previousRetainedCsvDirectory = File( - storageScope.rootDirectory(appContext), - "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", - ) + val retainedCsvDirectory = storageScope.retainedCsvDirectory(appContext) + val previousRetainedCsvDirectory = storageScope.previousRetainedCsvDirectory(appContext) try { PlatoonRepository.withExclusiveDatabase(storageScope) { beginRestoreTransaction( @@ -337,7 +283,7 @@ class PlatoonBackupManager internal constructor( restoreObserver(RestoreCheckpoint.COMMITTED) cleanupCommittedRestore( previousRetainedCsvDirectory, - previousDatabaseFile(), + previousDatabaseFile(appContext, storageScope), ) } } catch (error: Exception) { @@ -417,7 +363,7 @@ class PlatoonBackupManager internal constructor( private fun replaceDatabase(staged: File, preservePrevious: Boolean = false) { databaseFile.parentFile?.mkdirs() - val previous = previousDatabaseFile() + val previous = previousDatabaseFile(appContext, storageScope) if (previous.exists() && !previous.delete()) error("Unable to clear previous restore backup") listOf("-wal", "-shm", "-journal").forEach { suffix -> File(databaseFile.path + suffix).delete() @@ -459,11 +405,6 @@ class PlatoonBackupManager internal constructor( } } - private fun previousDatabaseFile() = File( - databaseFile.parentFile, - "${storageScope.databaseName}.pre_restore", - ) - private fun ensureDatabaseExists() { if (databaseFile.isFile) return PlatoonDatabase(appContext, storageScope.databaseName).use { helper -> @@ -555,24 +496,8 @@ class PlatoonBackupManager internal constructor( } val stagedDatabase = File.createTempFile("selected-", ".db", stagingDirectory) try { - val staged = try { - BackupArchive.stage(input, stagedDatabase) - } catch (error: IllegalArgumentException) { - throw InvalidBackupException(error) - } catch (error: ZipException) { - throw InvalidBackupException(error) - } catch (error: EOFException) { - throw InvalidBackupException(error) - } - val profile = try { - requireNotNull(staged.profile) { - "Unscoped legacy backups are no longer supported" - }.toProfile().also { - require(!it.legacy) { "Unscoped legacy backups are no longer supported" } - } - } catch (error: IllegalArgumentException) { - throw InvalidBackupException(error) - } + val staged = stageSelectedArchive(input, stagedDatabase) + val profile = validateSelectedBackup { staged.scopedProfile().toProfile() } val manager = PlatoonBackupManager( appContext, PlatoonStorageScope(profile.storageId), @@ -589,6 +514,30 @@ class PlatoonBackupManager internal constructor( } } + /** + * Converts expected archive, settings, and SQLite validation failures into a typed error. + * Provider I/O and local restore-state failures stay distinct for accurate UI reporting. + */ + private inline fun validateSelectedBackup(validation: () -> T): T = try { + validation() + } catch (error: IllegalArgumentException) { + throw InvalidBackupException(error) + } catch (error: SQLiteException) { + throw InvalidBackupException(error) + } catch (error: ZipException) { + throw InvalidBackupException(error) + } catch (error: EOFException) { + throw InvalidBackupException(error) + } + + private fun stageSelectedArchive(input: InputStream, stagedDatabase: File) = + validateSelectedBackup { BackupArchive.stage(input, stagedDatabase) } + + private fun BackupArchive.StagedArchive.scopedProfile(): BackupArchive.BackupPlatoonProfile = + requireNotNull(profile) { "Unscoped legacy backups are no longer supported" }.also { + require(!it.legacy) { "Unscoped legacy backups are no longer supported" } + } + internal fun recoverInterruptedFullRestore( context: Context, settingsStore: BackupSettingsStore? = null, @@ -658,18 +607,9 @@ class PlatoonBackupManager internal constructor( RestoreState.valueOf(stateFile.readText(Charsets.US_ASCII)) }.getOrElse { throw IllegalStateException("Invalid backup-restore transaction", it) } val database = appContext.getDatabasePath(scope.databaseName) - val previousDatabase = File( - database.parentFile, - "${scope.databaseName}.pre_restore", - ) - val retainedCsv = File( - scope.rootDirectory(appContext), - PlatoonRepository.RETAINED_CSV_DIRECTORY, - ) - val previousCsv = File( - scope.rootDirectory(appContext), - "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", - ) + val previousDatabase = previousDatabaseFile(appContext, scope) + val retainedCsv = scope.retainedCsvDirectory(appContext) + val previousCsv = scope.previousRetainedCsvDirectory(appContext) when (state) { RestoreState.PREPARED -> rollbackPreparedRestore( appContext, @@ -755,14 +695,8 @@ class PlatoonBackupManager internal constructor( context: Context, scope: PlatoonStorageScope, ) { - val directory = File( - scope.rootDirectory(context), - PlatoonRepository.RETAINED_CSV_DIRECTORY, - ) - val previous = File( - scope.rootDirectory(context), - "${PlatoonRepository.RETAINED_CSV_DIRECTORY}.pre_restore", - ) + val directory = scope.retainedCsvDirectory(context) + val previous = scope.previousRetainedCsvDirectory(context) if (!previous.exists()) return require(!directory.exists()) { "Both retained CSV state and a previous restore copy exist" @@ -772,14 +706,7 @@ class PlatoonBackupManager internal constructor( } } - // Function Name: cleanupLegacyRetiredCsvArtifacts - // Description: - // - Removes obsolete cache-only retained-CSV copies left by pre-v2 restore code. - // - Runs only when no current full-restore transaction state exists. - // Parameters: - // - context: Application context used to locate the private restore cache. - // Returns: - // - Returns normally after all obsolete artifacts are removed. + /** Removes obsolete cache-only retained-CSV copies left by pre-v2 restore code. */ private fun cleanupLegacyRetiredCsvArtifacts( context: Context, scope: PlatoonStorageScope, @@ -805,6 +732,11 @@ class PlatoonBackupManager internal constructor( scope.previousRetainedCsvDirectory(context).exists() || legacyRetiredCsvArtifacts(context, scope).isNotEmpty() + private fun previousDatabaseFile(context: Context, scope: PlatoonStorageScope) = File( + context.getDatabasePath(scope.databaseName).parentFile, + "${scope.databaseName}.pre_restore", + ) + private fun writeAtomic(file: File, bytes: ByteArray) { file.parentFile?.mkdirs() val atomicFile = AtomicFile(file) From eb6f3b83bf34f28350e4327d87ad157df9ad8109 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:08:26 +0900 Subject: [PATCH 014/125] fix: move capture teardown off the main thread and split the VPN service CaptureVpnService is now main-thread lifecycle glue. One service-lifetime CaptureWorker owns all flow, parser, session, and admission state; native chunks share its bounded budget while teardown and admission tasks are never rejected. Teardown joins the native thread, drains queued chunks, and clears state on that worker, and a start requested meanwhile waits for it. Pending-Platoon confirm and discard therefore also work after capture stopped. Flow routing moves to CaptureFlowEngine, admission to PlatoonAdmissionCoordinator, the notification to CaptureNotifications, counters to CaptureTrafficStats, and the one-time checklist to CaptureRunTracker. Package UIDs are resolved once per session, an unchanged 21905 no longer rewrites the profile registry, the duplicate routing paths and PROFILE checklist marks are merged, and the stale 'not integrated yet' message is replaced. --- .../app/capture/BoundedFlowPayloadBuffer.kt | 2 +- .../gf2log/app/capture/CaptureFlowEngine.kt | 322 +++++ .../gf2log/app/capture/CaptureFlowMetadata.kt | 44 +- .../app/capture/CaptureFlowStateCleanup.kt | 29 - .../app/capture/CaptureNotifications.kt | 57 + .../gf2log/app/capture/CaptureRunTracker.kt | 45 + .../gf2log/app/capture/CaptureTrafficStats.kt | 76 ++ .../gf2log/app/capture/CaptureVpnService.kt | 1031 +++-------------- .../dev/gf2log/app/capture/CaptureWorker.kt | 51 + .../capture/PlatoonAdmissionCoordinator.kt | 195 ++++ .../capture/CaptureFlowStateCleanupTest.kt | 60 - .../app/capture/CaptureRunTrackerTest.kt | 69 ++ .../app/capture/CaptureTrafficStatsTest.kt | 32 + .../gf2log/app/capture/CaptureWorkerTest.kt | 56 + 14 files changed, 1103 insertions(+), 966 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt delete mode 100644 app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/CaptureWorker.kt create mode 100644 app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt delete mode 100644 app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt create mode 100644 app/src/test/java/dev/gf2log/app/capture/CaptureWorkerTest.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 0b2974e..8658dcc 100644 --- a/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt +++ b/app/src/main/java/dev/gf2log/app/capture/BoundedFlowPayloadBuffer.kt @@ -2,7 +2,7 @@ package dev.gf2log.app.capture /** * Bounded pre-identity quarantine; overflow permanently rejects that flow until closure. - * All state access is serialized because rejected-close cleanup can run off the parser executor. + * Capture confines it to the worker thread; access stays serialized so sharing it remains safe. */ internal class BoundedFlowPayloadBuffer( private val maxItemsPerFlow: Int, diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt new file mode 100644 index 0000000..99bb3e2 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt @@ -0,0 +1,322 @@ +package dev.gf2log.app.capture + +import android.content.Context +import dev.gf2log.app.SupportedGamePackages +import dev.gf2log.app.history.CaptureHistoryStore +import dev.gf2log.app.management.PlatoonClient +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.PayloadHistoryPreferences +import dev.gf2log.protocol.Gfl2PayloadDecoder +import dev.gf2log.protocol.Gfl2StreamParser +import dev.gf2log.protocol.model.ParseEvent +import dev.gf2log.protocol.model.ParsedPayload +import dev.gf2log.protocol.model.PlatoonProfileData +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Parses each TCP flow, identifies its Platoon from 21905, and routes payloads to that verified + * scope. Everything except the [nativeListener] callbacks runs on the [CaptureWorker], which + * therefore owns all of this state without locking. + */ +internal class CaptureFlowEngine( + private val context: Context, + private val worker: CaptureWorker, + private val stats: CaptureTrafficStats, + private val profileRegistry: PlatoonProfileRegistry, + 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. */ + private val taintedFlows: MutableSet = ConcurrentHashMap.newKeySet() + private val parsers = HashMap() + private val metadata = HashMap() + private val sessions = HashMap() + private val preIdentity = BoundedFlowPayloadBuffer( + MAX_PRE_IDENTITY_PAYLOADS_PER_FLOW, + MAX_PRE_IDENTITY_PAYLOADS_TOTAL, + ) + private val historyStores = HashMap() + private val observedProfiles = HashMap>() + private var ownerResolver = CaptureFlowOwnerResolver(context) + private var tracker: CaptureRunTracker? = null + + val admission = PlatoonAdmissionCoordinator(profileRegistry, clientServerRegions, this) + + fun beginSession(tracker: CaptureRunTracker) { + this.tracker = tracker + ownerResolver = CaptureFlowOwnerResolver(context) + admission.beginSession() + } + + /** Discards per-flow state after the native thread has been joined and its chunks drained. */ + fun endSession() { + admission.endSession() + sessions.values.forEach { runCatching(it::close) } + sessions.clear() + parsers.clear() + metadata.clear() + taintedFlows.clear() + preIdentity.clear() + observedProfiles.clear() + tracker = null + } + + fun nativeListener(onStopped: () -> Unit) = object : NativeCaptureBridge.PayloadListener { + override fun onFlowOpened( + flowId: Long, + protocol: Int, + localAddress: String, + localPort: Int, + remoteAddress: String, + remotePort: Int, + ) { + submit { 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 + } + + 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 + } + + override fun onTraffic(sentBytes: Long, receivedBytes: Long, inspectedBytes: Long) { + stats.recordTraffic(sentBytes + receivedBytes, inspectedBytes)?.let { args -> + CaptureStatus.report(CaptureNotice.TRAFFIC, *args.toTypedArray()) + } + } + + override fun onCaptureStopped() = onStopped() + } + + override fun isOpen(flowId: Long): Boolean = flowId in metadata || flowId in parsers + + override fun bind(flowId: Long, profile: PlatoonProfile): PlatoonCaptureSession { + sessions.remove(flowId)?.close() + return PlatoonCaptureSession( + context = context, + profile = profile, + onRosterCaptured = { storageId -> tracker?.rosterCaptured(storageId) }, + ).also { sessions[flowId] = it } + } + + override fun unbind(flowId: Long, session: PlatoonCaptureSession) { + sessions.remove(flowId, session) + runCatching(session::close) + } + + /** Permanently blocks management routing for this flow until native closure. */ + override fun quarantine(flowId: Long) { + taintedFlows += flowId + sessions.remove(flowId)?.close() + preIdentity.reject(flowId) + } + + override fun profileChanged(storageId: String) { + observedProfiles.remove(storageId) + } + + override fun route(session: PlatoonCaptureSession, payload: ParsedPayload, flowEnded: Boolean) { + val storageId = session.profile.storageId + saveHistory(storageId, payload) + val routed = session.dispatch(payload, flowEnded) + routed.activity + ?.onSuccess { if (it) tracker?.mark(storageId, Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) } + ?.onFailure { CaptureStatus.report(CaptureNotice.ACTIVITY_SAVE_FAILED) } + routed.updates + ?.onSuccess { if (it) tracker?.mark(storageId, Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES) } + ?.onFailure { CaptureStatus.report(CaptureNotice.UPDATES_SAVE_FAILED) } + routed.members + .onSuccess { saved -> + if (saved != null) { + CaptureStatus.report( + CaptureNotice.ROSTER_SAVED, + saved.rowCount.toString(), + session.profile.platoonName, + ) + } + } + .onFailure { CaptureStatus.report(CaptureNotice.ROSTER_SAVE_FAILED) } + if (payload.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { + tracker?.mark(storageId, Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) + } + } + + 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, + localAddress: String, + localPort: Int, + remoteAddress: String, + remotePort: Int, + ) { + if (flowId in taintedFlows) return + metadata[flowId] = CaptureFlowMetadata( + protocol = protocol, + localAddress = localAddress, + localPort = localPort, + remoteAddress = remoteAddress, + remotePort = remotePort, + ownerPackage = ownerResolver.resolve( + protocol, + localAddress, + localPort, + remoteAddress, + remotePort, + ), + ) + } + + private fun accept(flowId: Long, payload: ByteArray) { + if (flowId in taintedFlows) return + process(flowId, parsers.getOrPut(flowId, ::Gfl2StreamParser).accept(payload)) + } + + private fun close(flowId: Long) { + val parser = parsers.remove(flowId) + if (!taintedFlows.remove(flowId) && parser != null) { + process(flowId, parser.finish(), flowEnded = true) + } + metadata.remove(flowId) + admission.flowClosed(flowId) + preIdentity.remove(flowId) + sessions.remove(flowId)?.close() + } + + private fun process(flowId: Long, events: List, flowEnded: Boolean = false) { + val payloads = mutableListOf() + var warnings = 0 + events.forEach { event -> + when (event) { + is ParseEvent.Payload -> payloads += event.value + is ParseEvent.Warning -> warnings += 1 + is ParseEvent.UnknownPayload -> stats.recordUnknown(event.payloadType) + } + } + stats.recordParsed(payloads.size, warnings) + + payloads.forEachIndexed { index, payload -> + val profile = payload.data as? PlatoonProfileData + if (profile != null && PlatoonProfilePolicy.isValid(profile)) identify(flowId, profile) + val session = sessions[flowId] + if (session == null) { + holdUntilIdentified(flowId, payload) + } else { + route(session, payload, flowEnded = flowEnded && index == payloads.lastIndex) + } + } + } + + private fun identify(flowId: Long, data: PlatoonProfileData) { + if (preIdentity.isRejected(flowId)) { + quarantine(flowId) + CaptureStatus.report(CaptureNotice.IDENTIFIED_FLOW_BUFFER_OVERFLOW) + return + } + val ownerPackage = metadata[flowId]?.ownerPackage + val client = PlatoonClient.fromPackage(ownerPackage) + if (ownerPackage == null || ownerPackage !in SupportedGamePackages.all || client == null) { + quarantine(flowId) + CaptureStatus.report(CaptureNotice.CLIENT_UNVERIFIED) + return + } + if (admission.claimsIdentity(flowId, ownerPackage, data)) return + + val known = findKnownProfile(client, ownerPackage, data) + if (known == null) { + admission.begin(flowId, ownerPackage, data, preIdentity.take(flowId)) + return + } + val current = sessions[flowId] + if (current != null && current.profile.storageId != known.storageId) { + quarantine(flowId) + CaptureStatus.report(CaptureNotice.IDENTITY_CHANGED) + return + } + val profile = observe(known.storageId, data) + if (profile == null) { + quarantine(flowId) + CaptureStatus.report(CaptureNotice.PROFILE_UPDATE_FAILED) + return + } + // An already bound flow only refreshes its profile; the caller routes this 21905 itself. + if (current != null) return + + val session = bind(flowId, profile) + preIdentity.take(flowId).forEach { pending -> route(session, pending, flowEnded = false) } + CaptureStatus.report( + CaptureNotice.PLATOON_DETECTED, + profile.platoonName.take(PlatoonAdmissionCoordinator.MAX_STATUS_NAME_CHARS), + profile.platoonId.toString(), + profile.client.displayName, + ) + } + + private fun findKnownProfile( + client: PlatoonClient, + ownerPackage: String, + data: PlatoonProfileData, + ): PlatoonProfile? { + val known = profileRegistry.findByClientAndPlatoonId(client, data.platoonId.toLong()) + return when { + known.size == 1 -> known.single() + known.size > 1 -> { + val configuredRegion = clientServerRegions.configured(ownerPackage) + known.singleOrNull { it.serverRegion == configuredRegion } + } + else -> null + } + } + + /** Persists an observed name or banner once per session, and again only when it changes. */ + private fun observe(storageId: String, data: PlatoonProfileData): PlatoonProfile? { + val identity = ObservedIdentity(data.platoonName, data.bannerFrameId, data.bannerMarkId) + observedProfiles[storageId]?.let { (observed, profile) -> if (observed == identity) return profile } + val profile = runCatching { profileRegistry.updateObserved(storageId, data) }.getOrNull() + ?: return null + observedProfiles[storageId] = identity to profile + 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 saveHistory(storageId: String, payload: ParsedPayload) { + if (!payloadHistory.isEnabled(payload.payloadType)) return + val store = historyStores.getOrPut(storageId) { + CaptureHistoryStore( + File( + PlatoonStorageScope(storageId).rootDirectory(context), + CaptureHistoryStore.HISTORY_DIRECTORY, + ), + ) + } + runCatching { store.save(payload) } + .onFailure { CaptureStatus.report(CaptureNotice.HISTORY_SAVE_FAILED) } + } + + private data class ObservedIdentity(val name: String, val bannerFrameId: UInt, val bannerMarkId: UInt) + + private companion object { + const val MAX_PRE_IDENTITY_PAYLOADS_PER_FLOW = 32 + const val MAX_PRE_IDENTITY_PAYLOADS_TOTAL = 128 + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt index 8dcbe2d..fac06ac 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowMetadata.kt @@ -16,43 +16,41 @@ internal data class CaptureFlowMetadata( val ownerPackage: String?, ) -/** Resolves an original VPN flow to a supported Android package without endpoint guessing. */ -internal object CaptureFlowOwnerResolver { +/** + * Resolves an original VPN flow to a supported Android package without endpoint guessing. + * One instance serves one capture session, so package UIDs are looked up once, not per flow. + */ +internal class CaptureFlowOwnerResolver(private val context: Context) { + private val packageByUid: Map by lazy { + SupportedGamePackages.all.mapNotNull { packageName -> + runCatching { + context.packageManager.getApplicationInfo(packageName, 0).uid to packageName + }.getOrNull() + }.toMap() + } + fun resolve( - context: Context, protocol: Int, localAddress: String, localPort: Int, remoteAddress: String, remotePort: Int, ): String? { - val visiblePackages = SupportedGamePackages.all.mapNotNull { packageName -> - runCatching { - packageName to context.packageManager.getApplicationInfo(packageName, 0).uid - }.getOrNull() - } val resolved = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val connectivity = context.getSystemService(ConnectivityManager::class.java) val local = InetSocketAddress(InetAddress.getByName(localAddress), localPort) val remote = InetSocketAddress(InetAddress.getByName(remoteAddress), remotePort) - val forwardUid = runCatching { - connectivity.getConnectionOwnerUid( - protocol, - local, - remote, - ) - }.getOrNull()?.takeIf { it >= 0 } - val forwardPackage = visiblePackages.firstOrNull { it.second == forwardUid }?.first - val reverseUid = if (forwardPackage == null) runCatching { - // Some vendor network stacks report VPN tuples in the opposite direction. - connectivity.getConnectionOwnerUid(protocol, remote, local) - }.getOrNull()?.takeIf { it >= 0 } else null - forwardPackage ?: visiblePackages.firstOrNull { it.second == reverseUid }?.first + fun owner(source: InetSocketAddress, destination: InetSocketAddress): String? = + runCatching { connectivity.getConnectionOwnerUid(protocol, source, destination) } + .getOrNull() + ?.takeIf { it >= 0 } + ?.let(packageByUid::get) + // Some vendor network stacks report VPN tuples in the opposite direction. + owner(local, remote) ?: owner(remote, local) } else { null } - val installed = visiblePackages.map { it.first } - return CaptureFlowOwnerPolicy.choose(resolved, installed) + return CaptureFlowOwnerPolicy.choose(resolved, packageByUid.values) } } diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt deleted file mode 100644 index 1e187e6..0000000 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowStateCleanup.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.gf2log.app.capture - -/** Removes every piece of per-flow state even when no stream parser was ever created. */ -internal object CaptureFlowStateCleanup { - /** - * Publishes metadata unless an out-of-band close rejection already quarantined the flow. - * The post-write check covers both possible races: close-before-write and close-after-write. - */ - fun registerUnlessQuarantined( - flowId: Long, - value: CaptureFlowMetadata, - metadata: MutableMap, - quarantinedFlows: Set, - ): Boolean { - metadata[flowId] = value - if (flowId !in quarantinedFlows) return true - metadata.remove(flowId) - return false - } - - fun remove( - flowId: Long, - parsers: MutableMap, - metadata: MutableMap, - ): T? { - metadata.remove(flowId) - return parsers.remove(flowId) - } -} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt new file mode 100644 index 0000000..a046a5e --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureNotifications.kt @@ -0,0 +1,57 @@ +package dev.gf2log.app.capture + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.StringRes +import dev.gf2log.app.R +import dev.gf2log.app.settings.CapturePreferences + +/** The foreground notification Android requires while VPN capture is active. */ +internal class CaptureNotifications(private val service: Service) { + private val preferences = CapturePreferences(service) + private val manager = service.getSystemService(NotificationManager::class.java) + + fun createChannel() { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL, + service.getString(R.string.notification_channel), + NotificationManager.IMPORTANCE_LOW, + ), + ) + } + + fun startForeground(@StringRes content: Int) { + val notification = build(content) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.startForeground(ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + service.startForeground(ID, notification) + } + } + + fun update(@StringRes content: Int) = manager.notify(ID, build(content)) + + fun remove() = service.stopForeground(Service.STOP_FOREGROUND_REMOVE) + + private fun build(@StringRes content: Int): Notification = + Notification.Builder(service, CHANNEL) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(service.getString(R.string.app_name)) + .setContentText( + service.getString( + if (preferences.detailedNotifications) content else R.string.notification_capture_active, + ), + ) + .setOngoing(true) + .build() + + private companion object { + const val CHANNEL = "capture" + const val ID = 1 + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt new file mode 100644 index 0000000..0bf8a33 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureRunTracker.kt @@ -0,0 +1,45 @@ +package dev.gf2log.app.capture + +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 + * capture reports completion and the target roster through callbacks that must hop to main. + */ +internal class CaptureRunTracker( + val captureOnce: Boolean, + private val onComplete: (CaptureRunTracker) -> Unit = {}, + private val onTargetRosterCaptured: (CaptureRunTracker) -> Unit = {}, +) { + private val checklist = ScopedCaptureChecklist(REQUIRED_TYPES) + + fun mark(storageId: String, payloadType: Int) { + checklist.mark(storageId, payloadType, chooseTarget = captureOnce) + if (!captureOnce || checklist.targetScopeId() == storageId) { + CaptureStatus.markUsefulPayload(payloadType) + } + if (captureOnce && checklist.targetComplete()) onComplete(this) + } + + fun rosterCaptured(storageId: String) { + mark(storageId, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) + 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? { + val captured = checklist.targetCaptured() + if (Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS !in captured) return null + return REQUIRED_TYPES.minus(captured).map(PayloadCatalog::tag) + } + + companion object { + val REQUIRED_TYPES = setOf( + Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, + Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, + Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, + Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, + ) + } +} diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt new file mode 100644 index 0000000..5000ebf --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureTrafficStats.kt @@ -0,0 +1,76 @@ +package dev.gf2log.app.capture + +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** One capture session's counters, written by the native and worker threads. */ +internal class CaptureTrafficStats { + private val forwardedBytes = AtomicLong() + private val inspectedBytes = AtomicLong() + private val decodedPayloads = AtomicLong() + private val warnings = AtomicLong() + private val droppedChunks = AtomicLong() + private val reportedBucket = AtomicLong() + private val unknownPayloads = ConcurrentHashMap() + + @Volatile + private var startedAt: Instant? = null + + fun begin(now: Instant = Instant.now()) { + listOf(forwardedBytes, inspectedBytes, decodedPayloads, warnings, droppedChunks, reportedBucket) + .forEach { it.set(0) } + unknownPayloads.clear() + startedAt = now + } + + fun recordParsed(decoded: Int, warnings: Int) { + if (decoded > 0) decodedPayloads.addAndGet(decoded.toLong()) + if (warnings > 0) this.warnings.addAndGet(warnings.toLong()) + } + + fun recordUnknown(payloadType: Int) { + unknownPayloads.computeIfAbsent(payloadType) { AtomicLong() }.incrementAndGet() + } + + fun recordDropped(): Long = droppedChunks.incrementAndGet() + + /** Returns [CaptureNotice.TRAFFIC] args each time forwarded traffic crosses another bucket. */ + fun recordTraffic(forwarded: Long, inspected: Long): List? { + forwardedBytes.set(forwarded) + inspectedBytes.set(inspected) + val bucket = forwarded / TRAFFIC_REPORT_BYTES + val previous = reportedBucket.get() + if (bucket <= previous || !reportedBucket.compareAndSet(previous, bucket)) return null + return listOf( + (forwarded / 1024).toString(), + (inspected / 1024).toString(), + decodedPayloads.get().toString(), + warnings.get().toString(), + droppedChunks.get().toString(), + ) + } + + /** Ends the session; returns null when no tunnel was established since [begin]. */ + fun finish(finalStatus: String, stoppedAt: Instant = Instant.now()): CaptureDiagnosticsStore.Diagnostics? { + val started = startedAt ?: return null + startedAt = null + return CaptureDiagnosticsStore.Diagnostics( + startedAt = started, + stoppedAt = stoppedAt, + forwardedBytes = forwardedBytes.get(), + inspectedBytes = inspectedBytes.get(), + decodedPayloads = decodedPayloads.get(), + warnings = warnings.get(), + droppedChunks = droppedChunks.get(), + unknownPayloads = unknownPayloads.entries + .sortedBy { it.key } + .joinToString { "${it.key}:${it.value.get()}" }, + finalStatus = finalStatus, + ) + } + + private companion object { + const val TRAFFIC_REPORT_BYTES = 64 * 1024 + } +} 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 46a2754..274527d 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -1,143 +1,88 @@ package dev.gf2log.app.capture -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.Service import android.content.Intent import android.content.pm.PackageManager -import android.content.pm.ServiceInfo import android.net.VpnService -import android.os.Build import android.os.Handler import android.os.Looper import android.os.ParcelFileDescriptor import dev.gf2log.app.R import dev.gf2log.app.SupportedGamePackages -import dev.gf2log.app.history.CaptureHistoryStore -import dev.gf2log.app.management.PlatoonClient -import dev.gf2log.app.management.PlatoonProfileIdentity import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope -import dev.gf2log.app.settings.CapturePreferences import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.settings.PayloadHistoryPreferences -import dev.gf2log.protocol.Gfl2StreamParser -import dev.gf2log.protocol.Gfl2PayloadDecoder -import dev.gf2log.protocol.PayloadCatalog -import dev.gf2log.protocol.model.ParseEvent -import dev.gf2log.protocol.model.ParsedPayload -import dev.gf2log.protocol.model.PlatoonProfileData -import java.io.File -import java.time.Instant -import java.util.concurrent.ArrayBlockingQueue -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicLong +/** + * Lifecycle glue between Android's VPN service, the native forwarding core, and the capture worker. + * + * Every field is confined to the main thread. Blocking work (history migration, joining the native + * thread, draining queued chunks) runs on [CaptureWorker], whose FIFO order guarantees teardown as + * "stop native, then drain parser, then clear state". A start requested during teardown waits for it. + */ class CaptureVpnService : VpnService() { - private var tunnel: ParcelFileDescriptor? = null - private val parsers = ConcurrentHashMap() - private val taintedFlows = ConcurrentHashMap.newKeySet() - private val flowMetadata = ConcurrentHashMap() - private val flowSessions = ConcurrentHashMap() - 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() - private val inspectedPayloadBytes = AtomicLong() - private val reportedTrafficBucket = AtomicLong() - private val parseWarningCount = AtomicLong() - private val droppedParserTaskCount = AtomicLong() - private val unknownPayloadCounts = ConcurrentHashMap() - private val captureChecklist = ScopedCaptureChecklist(REQUIRED_CAPTURE_TYPES) - private val profileAdmissionGate = PlatoonProfilePolicy.AdmissionGate() + private enum class State { IDLE, STARTING, RUNNING, STOPPING } + private val mainHandler = Handler(Looper.getMainLooper()) - private val historyStores = ConcurrentHashMap() + private val stats = CaptureTrafficStats() private lateinit var profileRegistry: PlatoonProfileRegistry - private lateinit var clientServerRegions: ClientServerRegionPreferences - private lateinit var payloadHistoryPreferences: PayloadHistoryPreferences - private lateinit var capturePreferences: CapturePreferences private lateinit var diagnosticsStore: CaptureDiagnosticsStore - private var sessionStartedAt: Instant? = null - private var captureOnce = false + private lateinit var notifications: CaptureNotifications + private lateinit var worker: CaptureWorker + private lateinit var engine: CaptureFlowEngine + private var state = State.IDLE + private var run: CaptureRunTracker? = null + private var tunnel: ParcelFileDescriptor? = null + private var restartCaptureOnce: Boolean? = null + private var destroyed = false + private val captureOnceGraceStop = Runnable { - val captured = captureChecklist.targetCaptured() - if (captureOnce && - CaptureStatus.isRunning && - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS in captured - ) { - val missing = REQUIRED_CAPTURE_TYPES - .minus(captured) - .map(PayloadCatalog::tag) - .joinToString() - stopCapture(CaptureNotice.CAPTURE_ONCE_PARTIAL, missing) - } - } - private val migrationExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2LegacyImport") + val missing = run?.takeIf { state == State.RUNNING }?.missingAfterRoster() ?: return@Runnable + stop(CaptureNotice.CAPTURE_ONCE_PARTIAL, missing.joinToString()) } - @Volatile - private var captureStartPending = false - private var parserExecutor = createParserExecutor() override fun onCreate() { super.onCreate() + CaptureStatus.attachLegacyContext(this) profileRegistry = PlatoonProfileRegistry(this) - clientServerRegions = ClientServerRegionPreferences(this) - payloadHistoryPreferences = PayloadHistoryPreferences(this) - capturePreferences = CapturePreferences(this) diagnosticsStore = CaptureDiagnosticsStore(this) - CaptureStatus.attachLegacyContext(this) - createNotificationChannel() + notifications = CaptureNotifications(this).also { it.createChannel() } + worker = CaptureWorker("GF2CaptureWorker", PARSER_QUEUE_CAPACITY) { error -> + CaptureStatus.report(CaptureNotice.PROCESSING_FAILED, error.javaClass.simpleName) + } + engine = CaptureFlowEngine( + context = this, + worker = worker, + stats = stats, + profileRegistry = profileRegistry, + clientServerRegions = ClientServerRegionPreferences(this), + payloadHistory = PayloadHistoryPreferences(this), + ) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { - ACTION_STOP -> stopCapture() - ACTION_START -> { - captureOnce = intent.getBooleanExtra(EXTRA_CAPTURE_ONCE, false) - CaptureStatus.beginSession(captureOnce) - startCapture() - } + ACTION_START -> start(intent.getBooleanExtra(EXTRA_CAPTURE_ONCE, false)) + ACTION_STOP -> stop() ACTION_CONFIRM_PENDING_PLATOON -> { val token = intent.getStringExtra(EXTRA_PENDING_TOKEN) - val region = GameServerRegion.fromStored( - intent.getStringExtra(EXTRA_SERVER_REGION), - ) + val region = GameServerRegion.fromStored(intent.getStringExtra(EXTRA_SERVER_REGION)) if (token.isNullOrBlank() || region == GameServerRegion.MANUAL) { CaptureStatus.report(CaptureNotice.ADMISSION_INVALID) - stopSelf(startId) - } else if (!submitParserTask { - confirmPendingPlatoon(token, region) - if (tunnel == null) stopSelf(startId) - } - ) { - CaptureStatus.report(CaptureNotice.PARSER_OVERLOADED) - stopSelf(startId) + stopIfIdle(startId) + } else { + runAdmission(startId) { engine.admission.confirm(token, region) } } } ACTION_DISCARD_PENDING_PLATOON -> { val token = intent.getStringExtra(EXTRA_PENDING_TOKEN) - if (!token.isNullOrBlank()) { - if (!submitParserTask { - discardPendingPlatoon(token) - if (tunnel == null) stopSelf(startId) - } - ) { - CaptureStatus.report(CaptureNotice.PARSER_OVERLOADED) - stopSelf(startId) - } + if (token.isNullOrBlank()) { + stopIfIdle(startId) } else { - stopSelf(startId) + runAdmission(startId) { engine.admission.discard(token) } } } } @@ -145,58 +90,109 @@ class CaptureVpnService : VpnService() { } override fun onRevoke() { - stopCapture() + stop() super.onRevoke() } override fun onDestroy() { - captureStartPending = false - releaseCaptureResources() - if (CaptureStatus.isRunning) CaptureStatus.markStopped() - mainHandler.removeCallbacksAndMessages(null) - migrationExecutor.shutdownNow() - closeAllFlowSessions() + destroyed = true + mainHandler.removeCallbacks(captureOnceGraceStop) + when (state) { + State.IDLE -> worker.shutdown() + // The teardown shuts the worker down once it completes. + State.STOPPING -> Unit + State.STARTING, State.RUNNING -> stop() + } super.onDestroy() } - private fun startCapture() { - if (tunnel != null) { - CaptureStatus.report(CaptureNotice.ALREADY_RUNNING) - updateNotification("Capturing selected game traffic") - return + /** Candidates outlive capture, so admission decisions also run while capture is stopped. */ + private fun runAdmission(startId: Int, task: () -> Unit) { + val queued = worker.execute { + try { + task() + } finally { + mainHandler.post { stopIfIdle(startId) } + } } - if (captureStartPending) { - CaptureStatus.report(CaptureNotice.PREPARING) - return + if (!queued) stopIfIdle(startId) + } + + private fun stopIfIdle(startId: Int) { + if (state == State.IDLE && !destroyed) stopSelf(startId) + } + + private fun start(captureOnce: Boolean) { + // Every startForegroundService request must be answered with a foreground notification. + notifications.startForeground( + if (state == State.RUNNING) { + R.string.capture_notification_running + } else { + R.string.capture_notification_preparing + }, + ) + when (state) { + State.RUNNING -> { + CaptureStatus.report(CaptureNotice.ALREADY_RUNNING) + return + } + State.STARTING -> { + CaptureStatus.report(CaptureNotice.PREPARING) + return + } + State.STOPPING -> { + restartCaptureOnce = captureOnce + CaptureStatus.markStarting() + return + } + State.IDLE -> Unit } - startInForeground("Preparing capture") - CaptureStatus.report(CaptureNotice.PREPARING) + state = State.STARTING + val tracker = CaptureRunTracker( + captureOnce = captureOnce, + onComplete = { completed -> + mainHandler.post { + if (run === completed && state == State.RUNNING) { + stop(CaptureNotice.CAPTURE_ONCE_COMPLETE) + } + } + }, + onTargetRosterCaptured = { captured -> + mainHandler.post { + if (run === captured) { + mainHandler.removeCallbacks(captureOnceGraceStop) + mainHandler.postDelayed(captureOnceGraceStop, CAPTURE_ONCE_GRACE_MILLIS) + } + } + }, + ) + run = tracker + CaptureStatus.beginSession(captureOnce) + CaptureStatus.markStarting() if (!NativeCaptureBridge.isAvailable) { - failStart(CaptureNotice.NATIVE_UNAVAILABLE) + stop(CaptureNotice.NATIVE_UNAVAILABLE) return } - - captureStartPending = true - migrationExecutor.execute { + worker.execute { val migration = runCatching { profileRegistry.ensureInitialized().forEach { profile -> - val scope = PlatoonStorageScope(profile.storageId) - PlatoonRepository(this, scope).reconcileRetainedCsvFiles() + PlatoonRepository(this, PlatoonStorageScope(profile.storageId)) + .reconcileRetainedCsvFiles() } } mainHandler.post { - if (!captureStartPending) return@post - captureStartPending = false - migration.fold( - onSuccess = { startTunnel() }, - onFailure = { failStart(CaptureNotice.HISTORY_IMPORT_FAILED) }, - ) + if (run !== tracker || state != State.STARTING) return@post + if (migration.isSuccess) { + establish(tracker) + } else { + stop(CaptureNotice.HISTORY_IMPORT_FAILED) + } } } } - private fun startTunnel() { + private fun establish(tracker: CaptureRunTracker) { val builder = Builder() .setSession("GF2logger") .setMtu(VPN_MTU) @@ -205,758 +201,99 @@ class CaptureVpnService : VpnService() { .addAddress(VPN_IPV6_ADDRESS, VPN_IPV6_PREFIX_LENGTH) .addRoute("::", 0) - val installedTargets = mutableListOf() - SupportedGamePackages.all.forEach { targetPackage -> + val installedTargets = SupportedGamePackages.all.filter { targetPackage -> try { builder.addAllowedApplication(targetPackage) - installedTargets += targetPackage + true } catch (_: PackageManager.NameNotFoundException) { - // A user may install either publisher's client or both. + false // A user may install either publisher's client or both. } } if (installedTargets.isEmpty()) { - failStart(CaptureNotice.NO_SUPPORTED_CLIENT) + stop(CaptureNotice.NO_SUPPORTED_CLIENT) return } val descriptor = try { builder.establish() } catch (error: Exception) { - failStart( - CaptureNotice.VPN_ESTABLISH_FAILED, - error.message ?: error.javaClass.simpleName, - ) + stop(CaptureNotice.VPN_ESTABLISH_FAILED, error.message ?: error.javaClass.simpleName) return } - if (descriptor == null) { - failStart(CaptureNotice.VPN_PERMISSION_REVOKED) + stop(CaptureNotice.VPN_PERMISSION_REVOKED) return } tunnel = descriptor - if (parserExecutor.isShutdown) { - parserExecutor = createParserExecutor() - } - parsers.clear() - taintedFlows.clear() - flowMetadata.clear() - closeAllFlowSessions() - pendingFlowPayloads.clear() - decodedPayloadCount.set(0) - observedPayloadBytes.set(0) - inspectedPayloadBytes.set(0) - reportedTrafficBucket.set(0) - parseWarningCount.set(0) - droppedParserTaskCount.set(0) - unknownPayloadCounts.clear() - captureChecklist.clear() - profileAdmissionGate.clear() - mainHandler.removeCallbacks(captureOnceGraceStop) - sessionStartedAt = Instant.now() - - val started = try { - NativeCaptureBridge.start( - descriptor.fd, - this, - object : NativeCaptureBridge.PayloadListener { - override fun onFlowOpened( - flowId: Long, - protocol: Int, - localAddress: String, - localPort: Int, - remoteAddress: String, - remotePort: Int, - ) { - enqueueFlowOpened( - flowId, - protocol, - localAddress, - localPort, - remoteAddress, - remotePort, - ) - } - - override fun onPayload(flowId: Long, payload: ByteArray) { - enqueuePayload(flowId, payload) - } + stats.begin() + // Queued before the native thread exists, so it precedes every native callback. + worker.execute { engine.beginSession(tracker) } - override fun onFlowClosed(flowId: Long) { - enqueueFlowClosed(flowId) - } - - override fun onTraffic( - sentBytes: Long, - receivedBytes: Long, - inspectedBytes: Long, - ) { - recordTraffic(sentBytes + receivedBytes, inspectedBytes) - } - - override fun onCaptureStopped() { - handleNativeCaptureStopped() - } - }, - ) - } catch (error: Exception) { - false + val listener = engine.nativeListener { + mainHandler.post { + if (run === tracker && state == State.RUNNING) stop(CaptureNotice.STOPPED_UNEXPECTEDLY) + } } + val started = runCatching { NativeCaptureBridge.start(descriptor.fd, this, listener) } + .getOrDefault(false) if (!started) { - failStart(CaptureNotice.NATIVE_START_FAILED) + stop(CaptureNotice.NATIVE_START_FAILED) return } + state = State.RUNNING CaptureStatus.markRunning(installedTargets) - updateNotification("Capturing selected game traffic") - } - - private fun enqueuePayload(flowId: Long, payload: ByteArray) { - if (payload.isEmpty()) return - if (flowId in taintedFlows) return - if (!submitParserTask { - if (flowId in taintedFlows) return@submitParserTask - val parser = parsers.computeIfAbsent(flowId) { Gfl2StreamParser() } - processEvents(flowId, parser.accept(payload)) - }) { - // A missing TCP chunk makes every later byte offset unreliable. - // Keep this flow quarantined until native closure instead of - // feeding a corrupted stream into the framing parser. - taintedFlows += flowId - } - } - - private fun enqueueFlowOpened( - flowId: Long, - protocol: Int, - localAddress: String, - localPort: Int, - remoteAddress: String, - remotePort: Int, - ) { - submitParserTask { - CaptureFlowStateCleanup.registerUnlessQuarantined( - flowId = flowId, - value = CaptureFlowMetadata( - protocol = protocol, - localAddress = localAddress, - localPort = localPort, - remoteAddress = remoteAddress, - remotePort = remotePort, - ownerPackage = CaptureFlowOwnerResolver.resolve( - this, - protocol, - localAddress, - localPort, - remoteAddress, - remotePort, - ), - ), - metadata = flowMetadata, - quarantinedFlows = taintedFlows, - ) - } - } - - private fun enqueueFlowClosed(flowId: Long) { - if (!submitParserTask { - val metadata = flowMetadata[flowId] - if (taintedFlows.remove(flowId)) { - CaptureFlowStateCleanup.remove(flowId, parsers, flowMetadata) - closeFlowSession(flowId) - return@submitParserTask - } - val parser = CaptureFlowStateCleanup.remove(flowId, parsers, flowMetadata) - if (parser != null) { - processEvents( - flowId = flowId, - events = parser.finish(), - metadata = metadata, - flowEnded = true, - ) - } - closeFlowSession(flowId) - } - ) { - CaptureFlowStateCleanup.remove(flowId, parsers, flowMetadata) - closeFlowSession(flowId) - // Earlier queued chunks may still run even though the close task was rejected. - // Keep the flow tainted until the capture session resets all parser state. - taintedFlows += flowId - } - } - - private fun submitParserTask(task: () -> Unit): Boolean = try { - parserExecutor.execute { - runCatching(task).onFailure { - CaptureStatus.report(CaptureNotice.PROCESSING_FAILED, it.javaClass.simpleName) - } - } - true - } catch (_: RejectedExecutionException) { - val dropped = droppedParserTaskCount.incrementAndGet() - CaptureStatus.report(CaptureNotice.PARSER_OVERLOADED, dropped.toString()) - false - } - - private fun processEvents( - flowId: Long, - events: List, - metadata: CaptureFlowMetadata? = flowMetadata[flowId], - flowEnded: Boolean = false, - ) { - val warnings = events.filterIsInstance() - if (warnings.isNotEmpty()) parseWarningCount.addAndGet(warnings.size.toLong()) - - val decoded = events.filterIsInstance() - events.filterIsInstance().forEach { event -> - unknownPayloadCounts.computeIfAbsent(event.payloadType) { AtomicLong() }.incrementAndGet() - } - decoded.forEachIndexed { index, event -> - if (event.value.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { - val profile = event.value.data as? PlatoonProfileData - if (PlatoonProfilePolicy.isValid(profile)) { - requireNotNull(profile) - identifyFlow(flowId, metadata, profile) - } - } - val session = flowSessions[flowId] - if (session == null) { - retainPendingPayload(flowId, event.value) - return@forEachIndexed - } - routeConfirmedPayload( - session, - event.value, - flowEnded = flowEnded && index == decoded.lastIndex, - ) - } - if (decoded.isNotEmpty()) decodedPayloadCount.addAndGet(decoded.size.toLong()) + notifications.update(R.string.capture_notification_running) } - private fun identifyFlow( - flowId: Long, - metadata: CaptureFlowMetadata?, - data: PlatoonProfileData, - ) { - if (pendingFlowPayloads.isRejected(flowId)) { - quarantineFlow(flowId) - CaptureStatus.report(CaptureNotice.IDENTIFIED_FLOW_BUFFER_OVERFLOW) - return - } - val ownerPackage = metadata?.ownerPackage - if (ownerPackage !in SupportedGamePackages.all) { - quarantineFlow(flowId) - CaptureStatus.report(CaptureNotice.CLIENT_UNVERIFIED) - return - } - val verifiedOwnerPackage = requireNotNull(ownerPackage) - val client = requireNotNull(PlatoonClient.fromPackage(verifiedOwnerPackage)) - pendingAdmissionByFlow[flowId]?.let { token -> - val pending = PendingPlatoonAdmissionStore.summary(token) - if ( - pending?.ownerPackage == verifiedOwnerPackage && - pending.profile.platoonId == data.platoonId - ) { + private fun stop(notice: CaptureNotice? = null, vararg args: String) { + mainHandler.removeCallbacks(captureOnceGraceStop) + restartCaptureOnce = null + val lastNotice = CaptureStatus.snapshot().notice + CaptureStatus.markStopped(notice, *args) + notifications.remove() + when (state) { + State.IDLE -> { + if (!destroyed) stopSelf() return } - discardPendingPlatoon(token) - quarantineFlow(flowId) - CaptureStatus.report(CaptureNotice.PENDING_IDENTITY_CHANGED) - return - } - val knownProfiles = profileRegistry.findByClientAndPlatoonId( - client, - data.platoonId.toLong(), - ) - val configuredRegion = clientServerRegions.configured(verifiedOwnerPackage) - val known = when { - knownProfiles.size == 1 -> knownProfiles.single() - knownProfiles.size > 1 -> knownProfiles.singleOrNull { - it.serverRegion == configuredRegion - } - else -> null - } - if (known == null) { - beginPendingPlatoon(flowId, verifiedOwnerPackage, data) - return - } - val current = flowSessions[flowId] - if (current != null && current.profile.storageId != known.storageId) { - quarantineFlow(flowId) - CaptureStatus.report(CaptureNotice.IDENTITY_CHANGED) - return - } - val profile = runCatching { - profileRegistry.updateObserved(known.storageId, data) - }.getOrElse { - quarantineFlow(flowId) - CaptureStatus.report(CaptureNotice.PROFILE_UPDATE_FAILED) - return + State.STOPPING -> return + State.STARTING, State.RUNNING -> Unit } - if (current?.profile?.storageId == profile.storageId) { - markRequiredPayloadCaptured( - profile.storageId, - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, - ) - return - } - val session = replaceFlowSession(flowId, profile) - pendingFlowPayloads.take(flowId).forEach { pending -> - routeConfirmedPayload(session, pending) - } - CaptureStatus.report( - CaptureNotice.PLATOON_DETECTED, - profile.platoonName.take(40), - profile.platoonId.toString(), - profile.client.displayName, - ) - markRequiredPayloadCaptured( - profile.storageId, - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, - ) - } - private fun retainPendingPayload(flowId: Long, payload: ParsedPayload) { - pendingAdmissionByFlow[flowId]?.let { token -> - when (val result = PendingPlatoonAdmissionStore.offer(token, flowId, payload)) { - PendingPlatoonAdmissionStore.OfferResult.Accepted -> Unit - PendingPlatoonAdmissionStore.OfferResult.Claimed -> - CaptureStatus.report(CaptureNotice.ADMISSION_IN_PROGRESS) - PendingPlatoonAdmissionStore.OfferResult.Missing -> { - pendingAdmissionByFlow.remove(flowId, token) - quarantineFlow(flowId) - } - is PendingPlatoonAdmissionStore.OfferResult.Overflow -> { - result.rejectedFlowIds.forEach { rejectedFlow -> - pendingAdmissionByFlow.remove(rejectedFlow, token) - quarantineFlow(rejectedFlow) - } - CaptureStatus.report(CaptureNotice.PENDING_OVERFLOW) - } - } - return - } - when (pendingFlowPayloads.offer(flowId, payload)) { - BoundedFlowPayloadBuffer.OfferResult.OVERFLOW -> - CaptureStatus.report(CaptureNotice.FLOW_BUFFER_OVERFLOW) - BoundedFlowPayloadBuffer.OfferResult.ACCEPTED, - BoundedFlowPayloadBuffer.OfferResult.REJECTED, - -> Unit - } - } - - private fun beginPendingPlatoon( - flowId: Long, - ownerPackage: String, - data: PlatoonProfileData, - ) { - val result = PendingPlatoonAdmissionStore.begin(ownerPackage, data, flowId) - val token = result.token - if (token == null) { - result.rejectedFlowIds.forEach(::quarantineFlow) - CaptureStatus.report(CaptureNotice.PENDING_QUEUE_FULL) - return - } - pendingAdmissionByFlow[flowId] = token - pendingFlowPayloads.take(flowId).forEach { pending -> - when (val offered = PendingPlatoonAdmissionStore.offer(token, flowId, pending)) { - PendingPlatoonAdmissionStore.OfferResult.Accepted -> Unit - is PendingPlatoonAdmissionStore.OfferResult.Overflow -> { - offered.rejectedFlowIds.forEach { rejected -> - pendingAdmissionByFlow.remove(rejected, token) - quarantineFlow(rejected) + state = State.STOPPING + run = null + val descriptor = tunnel + tunnel = null + worker.execute { + try { + // Joins the native thread; no callback can enqueue work after this returns. + NativeCaptureBridge.stop() + runCatching { descriptor?.close() } + } finally { + // Runs after every chunk the native thread queued before the join. + worker.execute { + try { + engine.endSession() + val finalNotice = CaptureStatus.snapshot().notice ?: lastNotice + stats.finish(finalNotice?.format(this).orEmpty())?.let(diagnosticsStore::save) + } finally { + mainHandler.post(::onTeardownComplete) } - CaptureStatus.report(CaptureNotice.PENDING_OVERFLOW) - return - } - PendingPlatoonAdmissionStore.OfferResult.Claimed, - PendingPlatoonAdmissionStore.OfferResult.Missing, - -> { - pendingAdmissionByFlow.remove(flowId, token) - quarantineFlow(flowId) - return } } } - CaptureStatus.report(CaptureNotice.PENDING_DETECTED) } - private fun confirmPendingPlatoon(token: String, region: GameServerRegion) { - val claim = PendingPlatoonAdmissionStore.claim(token) ?: return - val ownerPackage = claim.summary.ownerPackage - if (region !in clientServerRegions.allowed(ownerPackage)) { - PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.report(CaptureNotice.ADMISSION_REGION_MISMATCH) - return - } - val client = requireNotNull(PlatoonClient.fromPackage(ownerPackage)) - val existing = profileRegistry.findByIdentity( - client, - region, - claim.summary.profile.platoonId.toLong(), - ) - if (!profileAdmissionGate.canAdmit(ownerPackage, existing != null)) { - PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.report(CaptureNotice.ADMISSION_LIMIT) - return - } - val previousRegion = clientServerRegions.stored(ownerPackage) - val profile = runCatching { - clientServerRegions.set(ownerPackage, region) - profileRegistry.upsertDetected(ownerPackage, region, claim.summary.profile).also { - check(profileRegistry.setActive(it.storageId)) { - "Unable to select the confirmed Platoon" - } - } - }.getOrElse { error -> - runCatching { - if (previousRegion == null) { - clientServerRegions.clear(ownerPackage) - } else { - clientServerRegions.set(ownerPackage, previousRegion) - } - }.onFailure(error::addSuppressed) - PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.report(CaptureNotice.ADMISSION_PROFILE_FAILED) - return - } - if (existing == null) profileAdmissionGate.markAdmitted(ownerPackage) - - val sessions = mutableMapOf() - val routed = runCatching { - claim.flowIds.forEach { flowId -> - val session = replaceFlowSession(flowId, profile) - sessions[flowId] = session - if (flowId !in claim.endedFlowIds && flowMetadata.containsKey(flowId)) { - flowSessions[flowId] = session - } - } - val lastPayloadIndexByFlow = claim.payloads - .withIndex() - .associate { it.value.flowId to it.index } - claim.payloads.forEachIndexed { index, buffered -> - val session = sessions.getOrPut(buffered.flowId) { - replaceFlowSession(buffered.flowId, profile) - } - routeConfirmedPayload( - session = session, - payload = buffered.payload, - flowEnded = buffered.flowId in claim.endedFlowIds && - lastPayloadIndexByFlow[buffered.flowId] == index, - ) - } - } - if (routed.isFailure) { - sessions.forEach { (flowId, session) -> - flowSessions.remove(flowId, session) - runCatching(session::close) - } - PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.report(CaptureNotice.ADMISSION_REPLAY_FAILED) + private fun onTeardownComplete() { + state = State.IDLE + if (destroyed) { + worker.shutdown() return } - claim.endedFlowIds.forEach { flowId -> - sessions.remove(flowId)?.let { session -> - flowSessions.remove(flowId, session) - session.close() - } - } - PendingPlatoonAdmissionStore.complete(token).forEach { flowId -> - pendingAdmissionByFlow.remove(flowId, token) - } - CaptureStatus.report( - CaptureNotice.PLATOON_CONFIRMED, - profile.platoonName.take(40), - profile.platoonId.toString(), - profile.client.displayName, - ) - } - - private fun discardPendingPlatoon(token: String) { - PendingPlatoonAdmissionStore.discard(token).forEach { flowId -> - pendingAdmissionByFlow.remove(flowId, token) - if (flowMetadata.containsKey(flowId) || parsers.containsKey(flowId)) { - quarantineFlow(flowId) - } - } - CaptureStatus.report(CaptureNotice.PENDING_DISCARDED) - } - - private fun replaceFlowSession( - flowId: Long, - profile: dev.gf2log.app.management.PlatoonProfile, - ): PlatoonCaptureSession { - flowSessions.remove(flowId)?.close() - return PlatoonCaptureSession( - context = this, - profile = profile, - onRosterCaptured = ::markRosterCaptured, - ).also { flowSessions[flowId] = it } - } - - /** Permanently blocks management routing for this flow until native closure. */ - private fun quarantineFlow(flowId: Long) { - taintedFlows += flowId - flowSessions.remove(flowId)?.close() - pendingFlowPayloads.reject(flowId) - } - - private fun routePayload( - session: PlatoonCaptureSession, - payload: ParsedPayload, - flowEnded: Boolean = false, - ) { - val routed = session.dispatch(payload, flowEnded) - routed.activity?.onSuccess { accepted -> - if (accepted) { - markRequiredPayloadCaptured( - session.profile.storageId, - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, - ) - } - }?.onFailure { CaptureStatus.report(CaptureNotice.ACTIVITY_SAVE_FAILED) } - routed.updates?.onSuccess { accepted -> - if (accepted) { - markRequiredPayloadCaptured( - session.profile.storageId, - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, - ) - } - }?.onFailure { CaptureStatus.report(CaptureNotice.UPDATES_SAVE_FAILED) } - routed.members.onSuccess { saved -> - if (saved != null) { - CaptureStatus.report( - CaptureNotice.ROSTER_SAVED, - saved.rowCount.toString(), - session.profile.platoonName, - ) - } - }.onFailure { CaptureStatus.report(CaptureNotice.ROSTER_SAVE_FAILED) } - } - - private fun routeConfirmedPayload( - session: PlatoonCaptureSession, - payload: ParsedPayload, - flowEnded: Boolean = false, - ) { - saveHistoryOnly(session.profile.storageId, payload) - routePayload(session, payload, flowEnded) - if (payload.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { - markRequiredPayloadCaptured( - session.profile.storageId, - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, - ) - } - } - - private fun saveHistoryOnly(storageId: String, payload: ParsedPayload) { - if (!payloadHistoryPreferences.isEnabled(payload.payloadType)) return - val historyStore = historyStores.computeIfAbsent(storageId) { - val scope = PlatoonStorageScope(storageId) - CaptureHistoryStore( - File(scope.rootDirectory(this), CaptureHistoryStore.HISTORY_DIRECTORY), - ) - } - runCatching { historyStore.save(payload) } - .onFailure { CaptureStatus.report(CaptureNotice.HISTORY_SAVE_FAILED) } - } - - private fun closeFlowSession(flowId: Long) { - pendingAdmissionByFlow.remove(flowId)?.let { token -> - PendingPlatoonAdmissionStore.markFlowEnded(token, flowId) - } - pendingFlowPayloads.remove(flowId) - flowSessions.remove(flowId)?.close() - } - - private fun closeAllFlowSessions() { - flowSessions.values.forEach { runCatching { it.close() } } - flowSessions.clear() - } - - private fun maybeStopCaptureOnce() { - if (!captureOnce || !CaptureStatus.isRunning) return - if (!captureChecklist.targetComplete()) return - mainHandler.removeCallbacks(captureOnceGraceStop) - mainHandler.post { - if (captureOnce && - CaptureStatus.isRunning && - captureChecklist.targetComplete() - ) { - stopCapture(CaptureNotice.CAPTURE_ONCE_COMPLETE) - } - } - } - - private fun markRosterCaptured(storageId: String) { - markRequiredPayloadCaptured(storageId, Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) - if (captureOnce && captureChecklist.targetScopeId() == storageId) { - mainHandler.removeCallbacks(captureOnceGraceStop) - mainHandler.postDelayed(captureOnceGraceStop, CAPTURE_ONCE_GRACE_MILLIS) - } - } - - private fun markRequiredPayloadCaptured(storageId: String, payloadType: Int) { - captureChecklist.mark(storageId, payloadType, chooseTarget = captureOnce) - if (!captureOnce || captureChecklist.targetScopeId() == storageId) { - CaptureStatus.markUsefulPayload(payloadType) - } - maybeStopCaptureOnce() - } - - private fun recordTraffic(observed: Long, inspected: Long) { - observedPayloadBytes.set(observed) - inspectedPayloadBytes.set(inspected) - val bucket = observed / TRAFFIC_REPORT_BYTES - val previousBucket = reportedTrafficBucket.get() - if (bucket > previousBucket && reportedTrafficBucket.compareAndSet(previousBucket, bucket)) { - CaptureStatus.report( - CaptureNotice.TRAFFIC, - (observed / 1024).toString(), - (inspected / 1024).toString(), - decodedPayloadCount.get().toString(), - parseWarningCount.get().toString(), - droppedParserTaskCount.get().toString(), - ) - } - } - - private fun handleNativeCaptureStopped() { - mainHandler.post { - if (!CaptureStatus.isRunning) return@post - tunnel?.close() - tunnel = null - drainParserTasks() - parsers.clear() - flowMetadata.clear() - closeAllFlowSessions() - pendingFlowPayloads.clear() - CaptureStatus.markStopped(CaptureNotice.STOPPED_UNEXPECTEDLY) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - } - - private fun failStart(notice: CaptureNotice, vararg args: String) { - captureStartPending = false - releaseCaptureResources() - CaptureStatus.markStopped(notice, *args) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - private fun stopCapture(notice: CaptureNotice? = null, vararg args: String) { - captureStartPending = false - releaseCaptureResources() - CaptureStatus.markStopped(notice, *args) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - private fun releaseCaptureResources() { - NativeCaptureBridge.stop() - tunnel?.close() - tunnel = null - drainParserTasks() - parsers.clear() - taintedFlows.clear() - flowMetadata.clear() - closeAllFlowSessions() - pendingAdmissionByFlow.forEach { (flowId, token) -> - PendingPlatoonAdmissionStore.markFlowEnded(token, flowId) - } - pendingAdmissionByFlow.clear() - pendingFlowPayloads.clear() - saveDiagnostics() - } - - private fun saveDiagnostics() { - if (sessionStartedAt == null) return - diagnosticsStore.save( - CaptureDiagnosticsStore.Diagnostics( - startedAt = sessionStartedAt, - stoppedAt = Instant.now(), - forwardedBytes = observedPayloadBytes.get(), - inspectedBytes = inspectedPayloadBytes.get(), - decodedPayloads = decodedPayloadCount.get(), - warnings = parseWarningCount.get(), - droppedChunks = droppedParserTaskCount.get(), - unknownPayloads = unknownPayloadCounts.entries - .sortedBy { it.key } - .joinToString { "${it.key}:${it.value.get()}" }, - finalStatus = CaptureStatus.snapshot().notice?.format(this).orEmpty(), - ), - ) - sessionStartedAt = null - } - - private fun drainParserTasks() { - parserExecutor.shutdown() - val completed = try { - parserExecutor.awaitTermination(PARSER_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS) - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - false - } - if (!completed) { - val dropped = parserExecutor.shutdownNow().size - droppedParserTaskCount.addAndGet(dropped.toLong()) - } - } - - private fun createParserExecutor() = ThreadPoolExecutor( - 1, - 1, - 0, - TimeUnit.MILLISECONDS, - ArrayBlockingQueue(PARSER_QUEUE_CAPACITY), - { runnable -> Thread(runnable, "GF2ProtocolParser") }, - ThreadPoolExecutor.AbortPolicy(), - ) - - private fun startInForeground(content: String) { - val notification = buildNotification(notificationContent(content)) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, - ) - } else { - startForeground(NOTIFICATION_ID, notification) - } - } - - private fun updateNotification(content: String) { - getSystemService(NotificationManager::class.java) - .notify( - NOTIFICATION_ID, - buildNotification(notificationContent(content)), - ) - } - - private fun notificationContent(content: String): String = - if (capturePreferences.detailedNotifications) { - content - } else { - getString(R.string.notification_capture_active) - } - - private fun buildNotification(content: String): Notification = - Notification.Builder(this, NOTIFICATION_CHANNEL) - .setSmallIcon(R.drawable.ic_launcher_monochrome) - .setContentTitle(getString(R.string.app_name)) - .setContentText(content) - .setOngoing(true) - .build() - - private fun createNotificationChannel() { - val channel = NotificationChannel( - NOTIFICATION_CHANNEL, - getString(R.string.notification_channel), - NotificationManager.IMPORTANCE_LOW, - ) - getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + val restart = restartCaptureOnce + restartCaptureOnce = null + if (restart != null) start(restart) else stopSelf() } companion object { @@ -967,24 +304,12 @@ class CaptureVpnService : VpnService() { const val EXTRA_CAPTURE_ONCE = "capture_once" const val EXTRA_PENDING_TOKEN = "pending_platoon_token" const val EXTRA_SERVER_REGION = "pending_server_region" - private const val NOTIFICATION_CHANNEL = "capture" - private const val NOTIFICATION_ID = 1 private const val VPN_ADDRESS = "10.77.0.1" private const val VPN_PREFIX_LENGTH = 30 private const val VPN_IPV6_ADDRESS = "fd77:1::1" private const val VPN_IPV6_PREFIX_LENGTH = 120 private const val VPN_MTU = 1500 private const val PARSER_QUEUE_CAPACITY = 256 - private const val PARSER_DRAIN_TIMEOUT_SECONDS = 3L - 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, - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY, - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES, - ) } } diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureWorker.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureWorker.kt new file mode 100644 index 0000000..69e9769 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureWorker.kt @@ -0,0 +1,51 @@ +package dev.gf2log.app.capture + +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.atomic.AtomicInteger + +/** + * The single thread that owns every parser, flow, session, and admission structure. + * + * Native callbacks share a bounded chunk budget and are rejected when it is exhausted. Lifecycle + * and admission tasks bypass that budget, so teardown, confirm, and discard are never dropped + * while the worker is open. Tasks run in submission order. + */ +internal class CaptureWorker( + name: String, + private val chunkCapacity: Int, + private val onFailure: (Throwable) -> Unit, +) { + private val executor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, name) } + private val queuedChunks = AtomicInteger() + + init { + require(chunkCapacity > 0) + } + + fun submitChunk(task: () -> Unit): Boolean { + if (queuedChunks.incrementAndGet() > chunkCapacity) { + queuedChunks.decrementAndGet() + return false + } + val queued = execute { + try { + task() + } finally { + queuedChunks.decrementAndGet() + } + } + if (!queued) queuedChunks.decrementAndGet() + return queued + } + + fun execute(task: () -> Unit): Boolean = try { + executor.execute { runCatching(task).onFailure(onFailure) } + true + } catch (_: RejectedExecutionException) { + false + } + + /** Already queued tasks still run; later submissions are rejected. */ + fun shutdown() = executor.shutdown() +} diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt new file mode 100644 index 0000000..5ee471b --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt @@ -0,0 +1,195 @@ +package dev.gf2log.app.capture + +import dev.gf2log.app.management.PlatoonClient +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.protocol.model.ParsedPayload +import dev.gf2log.protocol.model.PlatoonProfileData + +/** + * Buffers a previously unknown Platoon identity in [PendingPlatoonAdmissionStore] and later + * confirms, replays, or discards it. Runs on the capture worker both during capture and after it + * stopped, because candidates stay in memory until the user decides or the process dies. + */ +internal class PlatoonAdmissionCoordinator( + private val profileRegistry: PlatoonProfileRegistry, + private val clientServerRegions: ClientServerRegionPreferences, + private val flows: Flows, +) { + /** The flow state an admission decision acts on; owned by [CaptureFlowEngine]. */ + interface Flows { + fun isOpen(flowId: Long): Boolean + fun bind(flowId: Long, profile: PlatoonProfile): PlatoonCaptureSession + fun unbind(flowId: Long, session: PlatoonCaptureSession) + fun route(session: PlatoonCaptureSession, payload: ParsedPayload, flowEnded: Boolean) + fun quarantine(flowId: Long) + fun profileChanged(storageId: String) + } + + private val tokenByFlow = HashMap() + private val admissionGate = PlatoonProfilePolicy.AdmissionGate() + + fun beginSession() = admissionGate.clear() + + fun endSession() { + tokenByFlow.forEach { (flowId, token) -> PendingPlatoonAdmissionStore.markFlowEnded(token, flowId) } + tokenByFlow.clear() + } + + fun flowClosed(flowId: Long) { + tokenByFlow.remove(flowId)?.let { PendingPlatoonAdmissionStore.markFlowEnded(it, flowId) } + } + + /** + * Returns true when [flowId] already feeds a candidate. A changed identity discards that + * candidate and quarantines the flow instead of re-binding it. + */ + fun claimsIdentity(flowId: Long, ownerPackage: String, data: PlatoonProfileData): Boolean { + val token = tokenByFlow[flowId] ?: return false + val pending = PendingPlatoonAdmissionStore.summary(token) + if (pending?.ownerPackage != ownerPackage || pending.profile.platoonId != data.platoonId) { + discard(token) + flows.quarantine(flowId) + CaptureStatus.report(CaptureNotice.PENDING_IDENTITY_CHANGED) + } + return true + } + + /** Returns true when [payload] was consumed by the candidate [flowId] belongs to. */ + fun retain(flowId: Long, payload: ParsedPayload): Boolean { + val token = tokenByFlow[flowId] ?: return false + offer(token, flowId, payload) + return true + } + + fun begin( + flowId: Long, + ownerPackage: String, + data: PlatoonProfileData, + preIdentityPayloads: List, + ) { + val result = PendingPlatoonAdmissionStore.begin(ownerPackage, data, flowId) + val token = result.token + if (token == null) { + result.rejectedFlowIds.forEach(flows::quarantine) + CaptureStatus.report(CaptureNotice.PENDING_QUEUE_FULL) + return + } + tokenByFlow[flowId] = token + preIdentityPayloads.forEach { if (!offer(token, flowId, it)) return } + CaptureStatus.report(CaptureNotice.PENDING_DETECTED) + } + + fun confirm(token: String, region: GameServerRegion) { + val claim = PendingPlatoonAdmissionStore.claim(token) ?: return + val ownerPackage = claim.summary.ownerPackage + if (region !in clientServerRegions.allowed(ownerPackage)) { + PendingPlatoonAdmissionStore.releaseClaim(token) + CaptureStatus.report(CaptureNotice.ADMISSION_REGION_MISMATCH) + return + } + val client = requireNotNull(PlatoonClient.fromPackage(ownerPackage)) + val existing = profileRegistry.findByIdentity( + client, + region, + claim.summary.profile.platoonId.toLong(), + ) + if (!admissionGate.canAdmit(ownerPackage, existing != null)) { + PendingPlatoonAdmissionStore.releaseClaim(token) + CaptureStatus.report(CaptureNotice.ADMISSION_LIMIT) + return + } + val previousRegion = clientServerRegions.stored(ownerPackage) + val profile = runCatching { + clientServerRegions.set(ownerPackage, region) + profileRegistry.upsertDetected(ownerPackage, region, claim.summary.profile).also { + check(profileRegistry.setActive(it.storageId)) { + "Unable to select the confirmed Platoon" + } + } + }.getOrElse { error -> + runCatching { + if (previousRegion == null) { + clientServerRegions.clear(ownerPackage) + } else { + clientServerRegions.set(ownerPackage, previousRegion) + } + }.onFailure(error::addSuppressed) + PendingPlatoonAdmissionStore.releaseClaim(token) + CaptureStatus.report(CaptureNotice.ADMISSION_PROFILE_FAILED) + return + } + if (existing == null) admissionGate.markAdmitted(ownerPackage) + flows.profileChanged(profile.storageId) + + val sessions = mutableMapOf() + val replayed = runCatching { + claim.flowIds.forEach { flowId -> sessions[flowId] = flows.bind(flowId, profile) } + val lastPayloadIndexByFlow = claim.payloads + .withIndex() + .associate { it.value.flowId to it.index } + claim.payloads.forEachIndexed { index, buffered -> + val session = sessions.getOrPut(buffered.flowId) { flows.bind(buffered.flowId, profile) } + flows.route( + session = session, + payload = buffered.payload, + flowEnded = buffered.flowId in claim.endedFlowIds && + lastPayloadIndexByFlow[buffered.flowId] == index, + ) + } + } + if (replayed.isFailure) { + sessions.forEach { (flowId, session) -> flows.unbind(flowId, session) } + PendingPlatoonAdmissionStore.releaseClaim(token) + CaptureStatus.report(CaptureNotice.ADMISSION_REPLAY_FAILED) + return + } + claim.endedFlowIds.forEach { flowId -> + sessions.remove(flowId)?.let { flows.unbind(flowId, it) } + } + PendingPlatoonAdmissionStore.complete(token).forEach { tokenByFlow.remove(it, token) } + CaptureStatus.report( + CaptureNotice.PLATOON_CONFIRMED, + profile.platoonName.take(MAX_STATUS_NAME_CHARS), + profile.platoonId.toString(), + profile.client.displayName, + ) + } + + fun discard(token: String) { + PendingPlatoonAdmissionStore.discard(token).forEach { flowId -> + tokenByFlow.remove(flowId, token) + if (flows.isOpen(flowId)) flows.quarantine(flowId) + } + CaptureStatus.report(CaptureNotice.PENDING_DISCARDED) + } + + /** 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)) { + PendingPlatoonAdmissionStore.OfferResult.Accepted -> true + PendingPlatoonAdmissionStore.OfferResult.Claimed -> { + CaptureStatus.report(CaptureNotice.ADMISSION_IN_PROGRESS) + true + } + PendingPlatoonAdmissionStore.OfferResult.Missing -> { + tokenByFlow.remove(flowId, token) + flows.quarantine(flowId) + false + } + is PendingPlatoonAdmissionStore.OfferResult.Overflow -> { + result.rejectedFlowIds.forEach { rejected -> + tokenByFlow.remove(rejected, token) + flows.quarantine(rejected) + } + CaptureStatus.report(CaptureNotice.PENDING_OVERFLOW) + false + } + } + + companion object { + const val MAX_STATUS_NAME_CHARS = 40 + } +} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt deleted file mode 100644 index 5a29fd2..0000000 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureFlowStateCleanupTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package dev.gf2log.app.capture - -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 CaptureFlowStateCleanupTest { - @Test - fun metadataIsRemovedWhenFlowNeverCreatedAParser() { - val parsers = mutableMapOf() - val metadata = mutableMapOf(7L to metadata()) - - assertNull(CaptureFlowStateCleanup.remove(7L, parsers, metadata)) - assertFalse(metadata.containsKey(7L)) - } - - @Test - fun parserAndMetadataAreRemovedTogether() { - val parsers = mutableMapOf(7L to "parser") - val metadata = mutableMapOf(7L to metadata()) - - assertEquals("parser", CaptureFlowStateCleanup.remove(7L, parsers, metadata)) - assertFalse(parsers.containsKey(7L)) - assertFalse(metadata.containsKey(7L)) - } - - @Test - fun queuedOpenCannotRestoreMetadataAfterRejectedCloseQuarantinesFlow() { - val metadata = mutableMapOf() - - assertFalse( - CaptureFlowStateCleanup.registerUnlessQuarantined( - flowId = 7L, - value = metadata(), - metadata = metadata, - quarantinedFlows = setOf(7L), - ), - ) - assertFalse(metadata.containsKey(7L)) - } - - @Test - fun liveFlowMetadataIsRegisteredNormally() { - val metadata = mutableMapOf() - - assertTrue( - CaptureFlowStateCleanup.registerUnlessQuarantined( - flowId = 7L, - value = metadata(), - metadata = metadata, - quarantinedFlows = emptySet(), - ), - ) - assertTrue(metadata.containsKey(7L)) - } - - private fun metadata() = CaptureFlowMetadata(6, "10.0.0.2", 1, "10.0.0.3", 2, null) -} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt new file mode 100644 index 0000000..7527b1f --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureRunTrackerTest.kt @@ -0,0 +1,69 @@ +package dev.gf2log.app.capture + +import dev.gf2log.protocol.Gfl2PayloadDecoder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CaptureRunTrackerTest { + @Before + fun setUp() { + MainThread.testExecutor = { it() } + CaptureStatus.beginSession(guided = true) + } + + @After + fun tearDown() { + CaptureStatus.beginSession(guided = false) + MainThread.testExecutor = null + } + + @Test + fun oneTimeCaptureCompletesOnlyForTheFirstScope() { + var completed = 0 + var rosters = 0 + val tracker = CaptureRunTracker( + captureOnce = true, + onComplete = { completed += 1 }, + onTargetRosterCaptured = { rosters += 1 }, + ) + + tracker.mark("first", Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) + assertNull(tracker.missingAfterRoster()) + tracker.rosterCaptured("second") + tracker.mark("second", Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) + tracker.mark("second", Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES) + assertEquals(0, rosters) + + tracker.rosterCaptured("first") + assertEquals(1, rosters) + assertEquals(listOf("ACTIVITY", "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( + CaptureRunTracker.REQUIRED_TYPES, + CaptureStatus.snapshot().guided?.capturedPayloadTypes, + ) + } + + @Test + fun continuousCaptureMarksEveryScopeWithoutStopping() { + var completed = 0 + val tracker = CaptureRunTracker(captureOnce = false, onComplete = { completed += 1 }) + + CaptureRunTracker.REQUIRED_TYPES.forEach { tracker.mark("any", it) } + + assertEquals(0, completed) + assertTrue( + CaptureStatus.snapshot().guided?.capturedPayloadTypes.orEmpty() + .containsAll(CaptureRunTracker.REQUIRED_TYPES), + ) + } +} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt new file mode 100644 index 0000000..70bbeff --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureTrafficStatsTest.kt @@ -0,0 +1,32 @@ +package dev.gf2log.app.capture + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.Instant + +class CaptureTrafficStatsTest { + @Test + fun trafficIsReportedOncePerBucketAndDiagnosticsOncePerSession() { + val stats = CaptureTrafficStats() + assertNull(stats.finish("never started")) + + stats.begin(Instant.EPOCH) + stats.recordParsed(decoded = 2, warnings = 1) + stats.recordUnknown(9) + stats.recordUnknown(9) + stats.recordUnknown(3) + stats.recordDropped() + + assertNull(stats.recordTraffic(forwarded = 1_000, inspected = 10)) + assertEquals(listOf("64", "1", "2", "1", "1"), stats.recordTraffic(64 * 1024, 1_024)) + assertNull(stats.recordTraffic(65 * 1024, 1_024)) + + val diagnostics = requireNotNull(stats.finish("done", Instant.ofEpochSecond(5))) + assertEquals(Instant.EPOCH, diagnostics.startedAt) + assertEquals(2L, diagnostics.decodedPayloads) + assertEquals("3:1, 9:2", diagnostics.unknownPayloads) + assertEquals("done", diagnostics.finalStatus) + assertNull(stats.finish("again")) + } +} diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureWorkerTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureWorkerTest.kt new file mode 100644 index 0000000..ca68e3b --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureWorkerTest.kt @@ -0,0 +1,56 @@ +package dev.gf2log.app.capture + +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.CountDownLatch +import java.util.concurrent.TimeUnit + +class CaptureWorkerTest { + @Test + fun chunkBudgetRejectsOverflowButNeverLifecycleTasks() { + val failures = mutableListOf() + val worker = CaptureWorker("test-worker", chunkCapacity = 2) { failures += it } + val gate = CountDownLatch(1) + val order = Collections.synchronizedList(mutableListOf()) + try { + assertTrue(worker.execute { gate.await(5, TimeUnit.SECONDS) }) + assertTrue(worker.submitChunk { order += "chunk-1" }) + assertTrue(worker.submitChunk { order += "chunk-2" }) + assertFalse(worker.submitChunk { order += "dropped" }) + assertTrue(worker.execute { order += "teardown" }) + + gate.countDown() + val drained = CountDownLatch(1) + worker.execute { drained.countDown() } + assertTrue(drained.await(5, TimeUnit.SECONDS)) + + assertEquals(listOf("chunk-1", "chunk-2", "teardown"), order) + assertTrue(worker.submitChunk { order += "chunk-3" }) + } finally { + worker.shutdown() + } + assertTrue(failures.isEmpty()) + } + + @Test + fun failingTasksAreReportedAndReleaseTheirBudget() { + val failures = Collections.synchronizedList(mutableListOf()) + val worker = CaptureWorker("test-worker", chunkCapacity = 1) { failures += it } + try { + repeat(3) { + val finished = CountDownLatch(1) + assertTrue(worker.submitChunk { error("boom") }) + worker.execute { finished.countDown() } + assertTrue(finished.await(5, TimeUnit.SECONDS)) + } + assertEquals(3, failures.size) + } finally { + worker.shutdown() + } + assertFalse(worker.execute {}) + assertFalse(worker.submitChunk {}) + } +} From c47a536bf95f237157e829b2f78cfd63a6cf1bc4 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:10:07 +0900 Subject: [PATCH 015/125] refactor: move weekly override merging into the domain layer Manual weekly edits are now merged with stored overrides by a tested policy that reports the invalid field instead of a generic failure. --- .../app/management/WeeklyOverrideEditing.kt | 102 ++++++++++++++++++ .../management/WeeklyOverrideEditingTest.kt | 81 ++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt new file mode 100644 index 0000000..46c15e2 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt @@ -0,0 +1,102 @@ +package dev.gf2log.app.management + +import java.time.LocalDate + +/** + * Merges the user's manual weekly-table edits with the overrides already stored for a week. + * + * Only explicitly edited fields change; every untouched field keeps its stored override value so + * derived certainty is never replaced by an accidental edit. The result is the complete override + * set for the week, because the repository replaces a week's overrides as one unit. + */ +object WeeklyOverrideEditing { + enum class Field { MERIT, SCORE, ATTEMPTS } + + /** A tri-state activity choice: unset clears the manual value, yes/no confirms it. */ + enum class Choice(val value: Boolean?) { + UNSET(null), + YES(true), + NO(false), + ; + + companion object { + fun of(value: Boolean?): Choice = entries.first { it.value == value } + } + } + + /** One cell's edits. A null property means that field was not edited. */ + data class CellEdit( + val uid: Long, + val gameDay: LocalDate, + val merit: String? = null, + val score: String? = null, + val attempts: String? = null, + val attended: Choice? = null, + val dailyPatrol: Choice? = null, + ) { + val isEmpty: Boolean + get() = merit == null && score == null && attempts == null && + attended == null && dailyPatrol == null + } + + sealed interface Result { + data class Valid(val overrides: List) : Result + data class Invalid(val uid: Long, val gameDay: LocalDate, val field: Field) : Result + } + + fun merge( + periodStart: LocalDate, + isGunsmokeWeek: Boolean, + existing: Collection, + edits: Collection, + ): Result { + val existingByCell = existing.associateBy { it.uid to it.gameDay } + val editsByCell = edits.filterNot(CellEdit::isEmpty).associateBy { it.uid to it.gameDay } + val merged = (existingByCell.keys + editsByCell.keys).mapNotNull { cell -> + 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 attempts = edit?.attempts?.let { parseAttempts(it) ?: return invalid(Field.ATTEMPTS) } + WeeklyCellOverride( + uid = cell.first, + periodStart = periodStart, + gameDay = cell.second, + meritDelta = if (edit?.merit != null) merit?.value else stored?.meritDelta, + scoreDelta = when { + !isGunsmokeWeek -> null + edit?.score != null -> score?.value + else -> stored?.scoreDelta + }, + attempts = when { + !isGunsmokeWeek -> null + edit?.attempts != null -> attempts?.value + else -> stored?.attempts + }, + attended = if (edit?.attended != null) edit.attended.value else stored?.attended, + dailyPatrol = if (edit?.dailyPatrol != null) edit.dailyPatrol.value else stored?.dailyPatrol, + ).takeIf { it.hasAnyValue() } + } + return Result.Valid(merged.sortedWith(compareBy({ it.gameDay }, { it.uid }))) + } + + private class Parsed(val value: T?) + + /** Blank clears the field; otherwise a non-negative integer is required. */ + private fun parseCounter(text: String): Parsed? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return Parsed(null) + return trimmed.toLongOrNull()?.takeIf { it >= 0 }?.let { Parsed(it) } + } + + private fun parseAttempts(text: String): Parsed? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return Parsed(null) + return trimmed.toIntOrNull()?.takeIf { it in 0..ActivityInference.MAX_DAILY_ATTEMPTS }?.let { Parsed(it) } + } + + private fun WeeklyCellOverride.hasAnyValue(): Boolean = + meritDelta != null || scoreDelta != null || attempts != null || + attended != null || dailyPatrol != null +} diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt new file mode 100644 index 0000000..49298af --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt @@ -0,0 +1,81 @@ +package dev.gf2log.app.management + +import dev.gf2log.app.management.WeeklyOverrideEditing.CellEdit +import dev.gf2log.app.management.WeeklyOverrideEditing.Choice +import dev.gf2log.app.management.WeeklyOverrideEditing.Field +import dev.gf2log.app.management.WeeklyOverrideEditing.Result +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WeeklyOverrideEditingTest { + private val week = LocalDate.of(2026, 10, 11) + private val day = week.plusDays(2) + + private fun stored( + merit: Long? = null, + score: Long? = null, + attempts: Int? = null, + attended: Boolean? = null, + patrol: Boolean? = null, + ) = WeeklyCellOverride(7, week, day, merit, score, attempts, attended, patrol) + + private fun valid(result: Result) = (result as Result.Valid).overrides + + @Test + fun untouchedStoredOverridesArePreserved() { + val existing = listOf(stored(merit = 90, attended = true)) + assertEquals(existing, valid(WeeklyOverrideEditing.merge(week, false, existing, emptyList()))) + } + + @Test + fun editedFieldsReplaceOnlyThemselves() { + val result = WeeklyOverrideEditing.merge( + week, + isGunsmokeWeek = true, + existing = listOf(stored(merit = 90, score = 100, attended = true)), + edits = listOf(CellEdit(7, day, score = "250", dailyPatrol = Choice.NO)), + ) + assertEquals(listOf(stored(merit = 90, score = 250, attended = true, patrol = false)), valid(result)) + } + + @Test + fun clearingEveryFieldRemovesTheOverride() { + val result = WeeklyOverrideEditing.merge( + week, + isGunsmokeWeek = false, + existing = listOf(stored(merit = 40, attended = true)), + edits = listOf(CellEdit(7, day, merit = " ", attended = Choice.UNSET)), + ) + assertTrue(valid(result).isEmpty()) + } + + @Test + fun standardWeeksNeverStoreGunsmokeFields() { + val result = WeeklyOverrideEditing.merge( + week, + isGunsmokeWeek = false, + existing = emptyList(), + edits = listOf(CellEdit(7, day, merit = "50", score = "300", attempts = "2")), + ) + assertEquals(listOf(stored(merit = 50)), valid(result)) + } + + @Test + fun invalidValuesIdentifyTheirField() { + assertEquals( + Result.Invalid(7, day, Field.ATTEMPTS), + WeeklyOverrideEditing.merge(week, true, emptyList(), listOf(CellEdit(7, day, attempts = "4"))), + ) + assertEquals( + Result.Invalid(7, day, Field.MERIT), + WeeklyOverrideEditing.merge(week, true, emptyList(), listOf(CellEdit(7, day, merit = "-1"))), + ) + } + + @Test + fun emptyEditsDoNotCreateOverrides() { + assertTrue(valid(WeeklyOverrideEditing.merge(week, true, emptyList(), listOf(CellEdit(7, day)))).isEmpty()) + } +} From 8954f59bcbd78ee20930d5b9a6fc4169c0e2bc30 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:10:07 +0900 Subject: [PATCH 016/125] feat: redesign Platoon roster and member screens - Platoon: virtualized roster with search, Active/Withdrawn/All filter, sort chooser, summary tiles, and long-press selection for CSV export. - Member detail: stats card, profile fields that survive recreation and are no longer wiped by history edits, and labelled membership periods. - Member order: drag handles with move up/down accessibility actions and confirmed reset; snapshot comparison moved off the main thread. - All repository work runs through lifecycle-bound background work. --- .../dev/gf2log/app/DateTimePickerInput.kt | 151 ++-- .../dev/gf2log/app/MemberDetailActivity.kt | 646 +++++++------- .../dev/gf2log/app/MemberOrderActivity.kt | 500 ++++++----- .../java/dev/gf2log/app/PlatoonActivity.kt | 834 ++++++++++-------- .../dev/gf2log/app/PlatoonProfileSelector.kt | 566 +++++------- .../gf2log/app/SnapshotComparisonActivity.kt | 227 +++-- .../main/java/dev/gf2log/app/ui/Dialogs.kt | 8 +- .../dev/gf2log/app/ui/SingleViewAdapter.kt | 44 + app/src/main/res/drawable/ic_schedule.xml | 9 + app/src/main/res/values-ko/strings.xml | 78 +- app/src/main/res/values/ids.xml | 2 + app/src/main/res/values/strings.xml | 80 +- 12 files changed, 1745 insertions(+), 1400 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/ui/SingleViewAdapter.kt create mode 100644 app/src/main/res/drawable/ic_schedule.xml diff --git a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt index f9a35e1..5bc6e87 100644 --- a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt +++ b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt @@ -3,14 +3,19 @@ package dev.gf2log.app import android.app.DatePickerDialog import android.app.TimePickerDialog import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.view.Gravity import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.EditText import android.widget.LinearLayout import android.widget.TextView -import android.widget.Toast import dev.gf2log.app.management.MembershipBoundaryValue +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.dpf +import dev.gf2log.app.ui.iconButton +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.text import java.time.Instant import java.time.LocalDate import java.time.LocalTime @@ -20,10 +25,9 @@ import java.time.format.DateTimeFormatter /** * Calendar input with an optional clock value. * - * Dates are stored explicitly so a date-only manual boundary remains stable if - * the device timezone later changes. A date-only value uses local midnight as - * its representative Instant, while [MembershipBoundaryValue.timeKnown] - * preserves that the time itself is unknown. + * Dates are stored explicitly so a date-only manual boundary remains stable if the device + * timezone later changes. A date-only value uses local midnight as its representative Instant, + * while [MembershipBoundaryValue.timeKnown] preserves that the time itself is unknown. */ internal class DateTimePickerInput( context: Context, @@ -32,7 +36,7 @@ internal class DateTimePickerInput( initialDate: LocalDate? = null, initialTimeKnown: Boolean = initialValue != null, private val dateRequired: Boolean = false, - editable: Boolean = true, + private val editable: Boolean = true, private val zone: ZoneId = ZoneId.systemDefault(), ) : LinearLayout(context) { private val selection = MembershipBoundaryDraft( @@ -42,79 +46,52 @@ internal class DateTimePickerInput( zone = zone, ) - val date: LocalDate? - get() = selection.date - val boundary: MembershipBoundaryValue? get() = selection.boundary - private val dateInput = pickerField(context.getString(R.string.date_unset)) { - showDatePicker() - } - private val timeInput = pickerField(context.getString(R.string.time_optional)) { - if (selection.date == null) { - TransientMessage.show(context, R.string.select_date_first) - } else { - showTimePicker() - } - } - private val clearDateButton = Button(context).apply { - text = context.getString(R.string.clear_date) - setOnClickListener { - selection.clearDate() - renderValue() - } - } - private val clearTimeButton = Button(context).apply { - text = context.getString(R.string.clear_time) - setOnClickListener { - selection.clearTime() - renderValue() - } + private val dateField = pickerField(R.drawable.ic_today) { showDatePicker() } + private val timeField = pickerField(R.drawable.ic_schedule) { showTimePicker() } + private val clearButton = context.iconButton(R.drawable.ic_close, context.getString(R.string.clear_date)) { + if (selection.time != null) selection.clearTime() else selection.clearDate() + renderValue() } init { orientation = VERTICAL - addView(TextView(context).apply { - text = label - setPadding(0, dp(6), 0, dp(2)) - }, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) - addView(LinearLayout(context).apply { - orientation = HORIZONTAL - addView(dateInput, LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - if (!dateRequired) { - addView( - clearDateButton, - LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - } - }, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) - addView(LinearLayout(context).apply { - orientation = HORIZONTAL - addView(timeInput, LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView( - clearTimeButton, - LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - }, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) - setBoundaryEditable(editable) + addView(context.text(label, TextRole.LABEL) { + setPadding(context.dp(4), 0, context.dp(4), context.dp(4)) + }, context.lp()) + addView(context.row { + addView(dateField, context.lp(width = 0, height = 48, weight = 1.3f)) + addView(timeField, context.lp(width = 0, height = 48, weight = 1f, start = 8)) + addView(clearButton, context.lp(48, 48, start = 4)) + }, context.lp()) + if (!editable) { + addView(context.text(context.getString(R.string.exact_boundary_read_only), TextRole.CAPTION) { + setPadding(context.dp(4), context.dp(4), context.dp(4), 0) + }, context.lp()) + } renderValue() } - private fun pickerField(hintText: String, onClick: () -> Unit) = - EditText(context).apply { - isFocusable = false - isClickable = true - hint = hintText - setSingleLine(true) - setOnClickListener { onClick() } + private fun pickerField(icon: Int, onClick: () -> Unit): TextView = context.text(null, TextRole.BODY) { + gravity = Gravity.CENTER_VERTICAL + setPadding(context.dp(12), 0, context.dp(8), 0) + maxLines = 1 + background = GradientDrawable().apply { + setColor(context.getColor(R.color.surface_variant)) + cornerRadius = context.dpf(12f) } + val image = requireNotNull(context.getDrawable(icon)).mutate() + image.setTint(context.getColor(R.color.text_secondary)) + val size = context.dp(18) + image.setBounds(0, 0, size, size) + setCompoundDrawablesRelative(image, null, null, null) + compoundDrawablePadding = context.dp(8) + isClickable = true + isFocusable = true + setOnClickListener { if (isEnabled) onClick() } + } private fun showDatePicker() { val seed = selection.date ?: LocalDate.now(zone) @@ -144,24 +121,26 @@ internal class DateTimePickerInput( ).show() } - private fun setBoundaryEditable(editable: Boolean) { - dateInput.isEnabled = editable - timeInput.isEnabled = editable - clearDateButton.visibility = if (editable && !dateRequired) View.VISIBLE else View.GONE - clearTimeButton.visibility = if (editable) View.VISIBLE else View.GONE - alpha = if (editable) 1f else 0.72f - } - private fun renderValue() { - dateInput.setText(selection.date?.format(DATE).orEmpty()) - timeInput.setText(selection.time?.format(TIME).orEmpty()) - timeInput.isEnabled = dateInput.isEnabled && selection.date != null - clearTimeButton.isEnabled = selection.date != null && selection.time != null + val date = selection.date + val time = selection.time + dateField.text = date?.format(DATE) ?: context.getString(R.string.date_unset) + dateField.setTextColor(context.getColor(if (date == null) R.color.text_secondary else R.color.text_primary)) + timeField.text = time?.format(TIME) ?: context.getString(R.string.time_optional) + timeField.setTextColor(context.getColor(if (time == null) R.color.text_secondary else R.color.text_primary)) + dateField.isEnabled = editable + timeField.isEnabled = editable && date != null + timeField.alpha = if (timeField.isEnabled) 1f else 0.6f + dateField.contentDescription = context.getString(R.string.date_field_description, dateField.text) + timeField.contentDescription = context.getString(R.string.time_field_description, timeField.text) + val canClear = editable && (time != null || (!dateRequired && date != null)) + clearButton.visibility = if (editable) View.VISIBLE else View.GONE + clearButton.isEnabled = canClear + clearButton.contentDescription = context.getString( + if (time != null) R.string.clear_time else R.string.clear_date, + ) } - private fun dp(value: Int): Int = - (value * resources.displayMetrics.density).toInt() - private companion object { val DATE: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index f9c9db7..f9b6bb5 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -1,385 +1,425 @@ package dev.gf2log.app import android.app.AlertDialog -import android.graphics.Typeface import android.os.Bundle -import android.view.ViewGroup +import android.text.Editable +import android.text.TextWatcher +import android.view.Gravity +import android.view.View import android.widget.Button import android.widget.EditText import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast +import dev.gf2log.app.management.EvidenceSource import dev.gf2log.app.management.MemberStatus import dev.gf2log.app.management.MembershipPeriod import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.management.isImmutableMembershipBoundary import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.MenuEntry +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.group +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField import java.time.Instant -import java.time.format.DateTimeFormatter +import java.time.LocalDate +import java.time.ZoneId +/** One member's profile, private note, and editable membership history. */ class MemberDetailActivity : BaseActivity() { + override val legacyStyling = false + + private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository - private var uid: Long = -1 + private lateinit var screen: Screen + private val work = BackgroundWork(this, "GF2MemberDetail") + private var uid = -1L + private var model: Model? = null + private var inputsInitialized = false + + private lateinit var content: LinearLayout + private lateinit var statusPanel: LinearLayout + private lateinit var periods: LinearLayout + private lateinit var nickname: EditText + private lateinit var note: EditText + private lateinit var saveButton: Button override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) uid = intent.getLongExtra(EXTRA_UID, -1) - repository = PlatoonRepository(this) - render() + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope + if (uid <= 0 || scope == null) { + finish() + return + } + repository = PlatoonRepository(this, scope) + inputsInitialized = savedInstanceState?.getBoolean(STATE_INPUTS_INITIALIZED) == true + screen = Screen(this) + screen.topBar + .setNavigation(::finish) + .setTitle(getString(R.string.member_details)) + .setActions( + emptyList(), + overflow = listOf(MenuEntry(getString(R.string.delete_member)) { confirmMemberDeletion() }), + ) + buildContent() + screen.setBody(Screen.scrollingBody(this, content)) } - private fun render() { - val status = repository.listMemberStatuses().firstOrNull { it.uid == uid } ?: run { + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) { + // The member belongs to the previous Platoon scope. finish() return } - val nameInput = EditText(this).apply { - hint = getString(R.string.member_nickname) - setText(status.name) - setSingleLine(true) + load() + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(STATE_INPUTS_INITIALIZED, inputsInitialized) + super.onSaveInstanceState(outState) + } + + private fun buildContent() { + nickname = textField(getString(R.string.member_nickname), id = R.id.member_nickname_input, maxLength = 256) + note = textField(getString(R.string.member_private_note_hint), id = R.id.member_note_input, singleLine = false) + saveButton = button(getString(R.string.save_member), ModernUi.ControlRole.PRIMARY) { saveProfile() } + val watcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = updateSaveState() + override fun afterTextChanged(s: Editable?) = Unit } - val noteInput = EditText(this).apply { - hint = getString(R.string.member_private_note) - setText(status.note) - minLines = 2 + nickname.addTextChangedListener(watcher) + note.addTextChangedListener(watcher) + statusPanel = panel() + periods = column() + content = column { + addView(statusPanel, lp(top = 4)) + addView(sectionHeader(getString(R.string.member_profile)), lp()) + addView(panel { + addView(LabeledField(context, getString(R.string.member_nickname), nickname, getString(R.string.member_nickname_detail)), lp()) + addView(LabeledField(context, getString(R.string.member_private_note), note, getString(R.string.member_private_note_detail)), lp(top = 12)) + addView(saveButton, lp(top = 12)) + }, lp()) + addView(row { + addView(sectionHeader(getString(R.string.membership_history)), lp(width = 0, weight = 1f)) + addView(button(getString(R.string.add_membership_history_short), ModernUi.ControlRole.TERTIARY, R.drawable.ic_add_circle) { + addMembershipPeriod() + }, lp(WRAP, WRAP, top = 8)) + }, lp()) + addView(periods, lp()) } - val content = ScrollView(this).apply { - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(16), dp(16), dp(16)) - addView(TextView(context).apply { - text = getString(R.string.member_details) - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - addView(TextView(context).apply { - text = getString( - R.string.member_status_line, - status.uid, - getString( - if (status.isActive) { - R.string.active_member - } else { - R.string.departed_member - }, - ), + statusPanel.addView(StateViews.loading(this, getString(R.string.loading_members))) + updateSaveState() + } + + private fun load() { + work.run( + key = LOAD, + task = { + val status = repository.listMemberStatuses().firstOrNull { it.uid == uid } + status?.let { + Model( + status = it, + latest = repository.listSnapshots(1).firstOrNull()?.members?.firstOrNull { member -> member.uid == uid }, + zone = GameTimeZonePreferences.get(this), ) - textSize = 14f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(4), 0, dp(8)) - }, matchWidth()) - addView(nameInput, matchWidth()) - addView(noteInput, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - addView(Button(context).apply { - text = getString(R.string.save_member) - usePrimaryActionStyle() - setOnClickListener { - val saved = runCatching { - repository.updateMember( - status.uid, - nameInput.text.toString(), - noteInput.text.toString(), - ) - }.getOrDefault(false) - TransientMessage.show( - this@MemberDetailActivity, - getString(if (saved) R.string.saved else R.string.save_failed), - Toast.LENGTH_SHORT, - ) - } - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { - marginEnd = dp(4) - }) - addView(Button(context).apply { - text = getString(R.string.add_membership_history_short) - useSecondaryActionStyle() - setOnClickListener { addMembershipHistory(status) } - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { - marginStart = dp(4) - }) - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.delete_member) - useDestructiveTextActionStyle() - setOnClickListener { confirmMemberDeletion(status) } - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.membership_history) - textSize = 17f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(16), 0, dp(4)) - }, matchWidth()) - status.membershipPeriods.forEachIndexed { index, membershipPeriod -> - addView(membershipPeriodRow(status, membershipPeriod, index), matchWidth()) } - }, matchWidth()) + }, + ) { result -> + val loaded = result.getOrNull() + if (loaded == null) { + if (result.isSuccess) finish() else render(null) + return@run + } + model = loaded + if (!inputsInitialized) { + nickname.setText(loaded.status.name) + note.setText(loaded.status.note) + inputsInitialized = true + } + render(loaded) } - setContentView(content) - ModernUi.prepareContent(window.decorView) } - private fun confirmMemberDeletion(status: MemberStatus) { - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.delete_member) - .setMessage(R.string.delete_member_warning) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.delete, null) - .create() - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { - setTextColor(getColor(R.color.destructive_action)) - setOnClickListener { - val deleted = runCatching { - repository.deleteMember(status.uid) - }.getOrDefault(false) - if (deleted) { - dialog.dismiss() - TransientMessage.show( - this@MemberDetailActivity, - R.string.member_deleted, - Toast.LENGTH_SHORT, - ) - finish() + private fun render(loaded: Model?) { + statusPanel.removeAllViews() + periods.removeAllViews() + if (loaded == null) { + statusPanel.addView(StateViews.failure(this, getString(R.string.platoon_load_failed)) { load() }) + return + } + val status = loaded.status + screen.topBar.setTitle(status.name, "#${status.uid}") + statusPanel.addView(row { + addView(text(status.name, TextRole.TITLE), lp(width = 0, weight = 1f)) + addView(badge( + getString(if (status.isActive) R.string.active_member else R.string.departed_member), + if (status.isActive) R.color.success_surface else R.color.surface_variant, + if (status.isActive) R.color.success_text else R.color.text_secondary, + ), lp(WRAP, WRAP)) + }, lp()) + val latest = loaded.latest + val stats = listOf( + getString(R.string.level) to (latest?.level ?: status.level).toString(), + getString(R.string.merit_this_week) to (latest?.weeklyMerit?.toString() ?: "—"), + getString(R.string.total_merit) to (latest?.totalMerit?.toString() ?: "—"), + getString(R.string.last_login) to (latest?.lastLogin?.takeIf { it > 0 }?.let { + Labels.format(Labels.DATE_TIME, Instant.ofEpochSecond(it), loaded.zone) + } ?: "—"), + ) + stats.chunked(2).forEach { pair -> + statusPanel.addView(row(Gravity.TOP) { + pair.forEachIndexed { index, (label, value) -> + addView(column { + isFocusable = true + contentDescription = "$label: $value" + addView(text(label, TextRole.CAPTION) { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp()) + addView(text(value, TextRole.SUBTITLE) { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(top = 2)) + }, lp(width = 0, weight = 1f, start = if (index == 0) 0 else 12)) + } + }, lp(top = 12)) + } + val rows = status.membershipPeriods.mapIndexed { index, period -> + navigationRow( + title = getString(R.string.membership_period_title, index + 1) + + if (period.leftAt == null && period.leftDate == null) " · " + getString(R.string.current_period) else "", + detail = getString( + R.string.membership_period_detail, + boundaryText(period.joinedDate, period.joinedAt, period.joinedTimeKnown, loaded.zone), + if (period.leftAt == null && period.leftDate == null) { + getString(R.string.present) } else { - TransientMessage.show( - this@MemberDetailActivity, - R.string.member_delete_failed, - Toast.LENGTH_SHORT, - ) - } + boundaryText(period.leftDate, period.leftAt, period.leftTimeKnown ?: (period.leftAt != null), loaded.zone) + }, + sourceLabel(period.joinedSource), + period.leftSource?.let(::sourceLabel) ?: "—", + ) + period.note.takeIf(String::isNotBlank)?.let { "\n$it" }.orEmpty(), + icon = R.drawable.ic_calendar, + ) { editMembershipPeriod(status, period) } + } + periods.addView( + if (rows.isEmpty()) StateViews.empty(this, R.drawable.ic_calendar, getString(R.string.no_membership_periods)) else group(rows), + lp(), + ) + updateSaveState() + } + + 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() + } + + private fun saveProfile() { + val name = nickname.text.toString() + val noteText = note.text.toString() + work.run(key = SAVE, task = { repository.updateMember(uid, name, noteText) }) { result -> + Feedback.show(this, if (result.getOrDefault(false)) R.string.saved else R.string.save_failed) + load() + } + updateSaveState() + } + + private fun confirmMemberDeletion() { + Dialogs.confirm(this, R.string.delete_member, R.string.delete_member_warning, R.string.delete, destructive = true) { + work.run(task = { repository.deleteMember(uid) }) { result -> + if (result.getOrDefault(false)) { + Feedback.show(this, R.string.member_deleted) + finish() + } else { + Feedback.show(this, R.string.member_delete_failed) } } } - dialog.show() } - private fun membershipPeriodRow( - status: MemberStatus, - membershipPeriod: MembershipPeriod, - index: Int, - ) = ModernUi.actionRow( - context = this, - title = getString(R.string.membership_period_title, index + 1), - detail = getString( - R.string.membership_period_dates, - format( - membershipPeriod.joinedDate, - membershipPeriod.joinedAt, - membershipPeriod.joinedTimeKnown, - ), - format( - membershipPeriod.leftDate, - membershipPeriod.leftAt, - membershipPeriod.leftTimeKnown ?: (membershipPeriod.leftAt != null), - ), - ), - onClick = { editMembershipPeriod(status, membershipPeriod) }, - ) - - private fun editMembershipPeriod(status: MemberStatus, membershipPeriod: MembershipPeriod) { + private fun editMembershipPeriod(status: MemberStatus, period: MembershipPeriod) { + val zone = model?.zone ?: return val joined = DateTimePickerInput( context = this, - label = getString(R.string.join_field), - initialValue = membershipPeriod.joinedAt, - initialDate = membershipPeriod.joinedDate, - initialTimeKnown = membershipPeriod.joinedTimeKnown, + label = getString(R.string.join_label), + initialValue = period.joinedAt, + initialDate = period.joinedDate, + initialTimeKnown = period.joinedTimeKnown, dateRequired = true, - editable = !membershipPeriod.joinedSource.isImmutableMembershipBoundary(), - zone = GameTimeZonePreferences.get(this), + editable = !period.joinedSource.isImmutableMembershipBoundary(), + zone = zone, ) val left = DateTimePickerInput( context = this, - label = getString(R.string.withdraw_field), - initialValue = membershipPeriod.leftAt, - initialDate = membershipPeriod.leftDate, - initialTimeKnown = membershipPeriod.leftTimeKnown ?: (membershipPeriod.leftAt != null), - editable = membershipPeriod.leftSource?.isImmutableMembershipBoundary() != true, - zone = GameTimeZonePreferences.get(this), + label = getString(R.string.withdraw_label), + initialValue = period.leftAt, + initialDate = period.leftDate, + initialTimeKnown = period.leftTimeKnown ?: (period.leftAt != null), + editable = period.leftSource?.isImmutableMembershipBoundary() != true, + zone = zone, ) - val note = EditText(this).apply { - hint = getString(R.string.membership_note_hint) - setText(membershipPeriod.note) + val noteInput = textField(getString(R.string.membership_note_hint), singleLine = false).apply { setText(period.note) } + val error = errorText() + val canDelete = status.membershipPeriods.size > 1 + val body = Dialogs.content(this) { + addView(joined, lp()) + addView(left, lp(top = 12)) + addView(LabeledField(context, getString(R.string.membership_note_hint), noteInput), lp(top = 12)) + addView(error, lp(top = 8)) + if (!canDelete) addView(text(getString(R.string.membership_period_delete_last_hint), TextRole.CAPTION), lp(top = 8)) } - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), 0, dp(16), 0) - addView(joined, matchWidth()) - addView(left, matchWidth()) - addView(note, matchWidth()) - if (status.membershipPeriods.size == 1) { - addView(TextView(context).apply { - setText(R.string.membership_period_delete_last_hint) - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(8), 0, 0) - }, matchWidth()) - } - } - val dialog = AlertDialog.Builder(this) + val builder = Dialogs.builder(this) .setTitle(R.string.edit_membership) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.delete, null) + .setView(body) + .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.save_member, null) - .create() - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_NEUTRAL).apply { + if (canDelete) builder.setNeutralButton(R.string.delete, null) + Dialogs.show(builder) { dialog -> + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.apply { setTextColor(getColor(R.color.destructive_action)) - isEnabled = status.membershipPeriods.size > 1 - setOnClickListener { - if (isEnabled) confirmMembershipPeriodDeletion(dialog, membershipPeriod) - } + setOnClickListener { confirmMembershipPeriodDeletion(dialog, period) } } - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> val joinedBoundary = joined.boundary val leftBoundary = left.boundary - val saved = joinedBoundary != null && - isValidMembershipRange(joinedBoundary, leftBoundary) && - runCatching { - repository.updateMembershipPeriod( - membershipPeriod.id, - joinedBoundary, - leftBoundary, - note.text.toString(), - ) - }.getOrDefault(false) - TransientMessage.show( - this, - getString(if (saved) R.string.saved else R.string.invalid_date), - Toast.LENGTH_SHORT, - ) - if (saved) { - dialog.dismiss() - render() + if (joinedBoundary == null || !isValidMembershipRange(joinedBoundary, leftBoundary)) { + error.show(R.string.invalid_date) + return@setOnClickListener } - } - } - dialog.show() - } - - private fun confirmMembershipPeriodDeletion( - editor: AlertDialog, - membershipPeriod: MembershipPeriod, - ) { - val confirmation = AlertDialog.Builder(this) - .setTitle(R.string.delete_membership_period) - .setMessage(R.string.delete_membership_period_warning) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.delete, null) - .create() - confirmation.setOnShowListener { - confirmation.getButton(AlertDialog.BUTTON_POSITIVE).apply { - setTextColor(getColor(R.color.destructive_action)) - setOnClickListener { - val deleted = runCatching { - repository.deleteMembershipPeriod(membershipPeriod.id) - }.getOrDefault(false) - if (deleted) { - confirmation.dismiss() - editor.dismiss() - TransientMessage.show( - this@MemberDetailActivity, - R.string.membership_period_deleted, - Toast.LENGTH_SHORT, - ) - render() + button.isEnabled = false + work.run( + task = { repository.updateMembershipPeriod(period.id, joinedBoundary, leftBoundary, noteInput.text.toString()) }, + ) { result -> + button.isEnabled = true + if (result.getOrDefault(false)) { + dialog.dismiss() + Feedback.show(this, R.string.saved) + load() } else { - TransientMessage.show( - this@MemberDetailActivity, - R.string.membership_period_delete_failed, - Toast.LENGTH_SHORT, - ) + error.show(R.string.membership_period_save_failed) } } } } - confirmation.show() } - private fun addMembershipHistory(status: MemberStatus) { - val joined = DateTimePickerInput( + private fun confirmMembershipPeriodDeletion(editor: AlertDialog, period: MembershipPeriod) { + Dialogs.confirm( this, - getString(R.string.join_field), - dateRequired = true, - zone = GameTimeZonePreferences.get(this), - ) - val withdrew = DateTimePickerInput( - this, - getString(R.string.withdraw_field), - zone = GameTimeZonePreferences.get(this), - ) - val note = EditText(this).apply { - hint = getString(R.string.membership_note_hint) - minLines = 2 + R.string.delete_membership_period, + R.string.delete_membership_period_warning, + R.string.delete, + destructive = true, + ) { + work.run(task = { repository.deleteMembershipPeriod(period.id) }) { result -> + if (result.getOrDefault(false)) { + editor.dismiss() + Feedback.show(this, R.string.membership_period_deleted) + load() + } else { + Feedback.show(this, R.string.membership_period_delete_failed, long = true) + } + } } - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), 0, dp(16), 0) - addView(joined, matchWidth()) - addView(withdrew, matchWidth()) - addView(note, matchWidth()) + } + + private fun addMembershipPeriod() { + 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) + val error = errorText() + val body = Dialogs.content(this) { + addView(joined, lp()) + addView(withdrew, lp(top = 12)) + addView(LabeledField(context, getString(R.string.membership_note_hint), noteInput), lp(top = 12)) + addView(error, lp(top = 8)) } - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.add_membership_history) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.add, null) - .create() - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.add_membership_history) + .setView(body) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.add, null), + ) { dialog -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> val joinedBoundary = joined.boundary val withdrewBoundary = withdrew.boundary - val saved = joinedBoundary != null && - isValidMembershipRange(joinedBoundary, withdrewBoundary) && - runCatching { - repository.addMembershipPeriod( - status.uid, - joinedBoundary, - withdrewBoundary, - note.text.toString(), - ) - }.getOrDefault(false) - if (saved) { - dialog.dismiss() - render() - } else { - TransientMessage.show(this, R.string.invalid_date) + if (joinedBoundary == null || !isValidMembershipRange(joinedBoundary, withdrewBoundary)) { + error.show(R.string.invalid_date) + return@setOnClickListener + } + button.isEnabled = false + work.run( + task = { repository.addMembershipPeriod(uid, joinedBoundary, withdrewBoundary, noteInput.text.toString()) }, + ) { result -> + button.isEnabled = true + if (result.getOrDefault(false)) { + dialog.dismiss() + load() + } else { + error.show(R.string.membership_period_overlap) + } } } } - dialog.show() } - private fun format( - date: java.time.LocalDate?, - instant: Instant?, - timeKnown: Boolean, - ): String { - val zone = GameTimeZonePreferences.get(this) - val displayDate = date ?: instant?.atZone(zone)?.toLocalDate() - ?: return getString(R.string.unknown) + private fun errorText() = text(null, TextRole.CAPTION) { + setTextColor(getColor(R.color.destructive_action)) + visibility = View.GONE + } + + private fun android.widget.TextView.show(message: Int) { + text = getString(message) + visibility = View.VISIBLE + announceForAccessibility(text) + } + + private fun boundaryText(date: LocalDate?, instant: Instant?, timeKnown: Boolean, zone: ZoneId): String { + val displayDate = date ?: instant?.atZone(zone)?.toLocalDate() ?: return getString(R.string.unknown) return if (timeKnown && instant != null) { - instant.atZone(zone).format(DISPLAY_TIME) + Labels.format(Labels.DATE_TIME, instant, zone) } else { - displayDate.format(DISPLAY_DATE) + Labels.format(Labels.DATE, displayDate) } } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, + private fun sourceLabel(source: EvidenceSource): String = getString( + when (source) { + EvidenceSource.GAME_UPDATES -> R.string.source_updates + EvidenceSource.SNAPSHOT -> R.string.source_roster + EvidenceSource.MANUAL -> R.string.source_manual + EvidenceSource.LEGACY_IMPORT -> R.string.source_legacy + }, + ) + + private data class Model( + val status: MemberStatus, + val latest: SnapshotMember?, + val zone: ZoneId, ) companion object { const val EXTRA_UID = "uid" - private val DISPLAY_TIME = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm") - private val DISPLAY_DATE = DateTimeFormatter.ofPattern("yy/MM/dd") + private const val LOAD = "load" + private const val SAVE = "save" + private const val STATE_INPUTS_INITIALIZED = "member.inputs_initialized" } } diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index 6e5c3bd..2bab2be 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -1,22 +1,22 @@ package dev.gf2log.app -import android.animation.LayoutTransition -import android.content.ClipData -import android.graphics.drawable.GradientDrawable -import android.graphics.Typeface +import android.annotation.SuppressLint import android.os.Bundle -import android.view.DragEvent +import android.text.TextUtils import android.view.Gravity import android.view.HapticFeedbackConstants +import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.Button import android.widget.ImageView import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.Spinner import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import dev.gf2log.app.management.MemberOrderSorter import dev.gf2log.app.management.MemberSortDirection import dev.gf2log.app.management.MemberSortField @@ -24,266 +24,308 @@ import dev.gf2log.app.management.MemberStatus import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.settings.MemberOrderPreferences +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.MATCH +import dev.gf2log.app.ui.MenuEntry +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.SingleViewAdapter +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.dpf +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.text +/** + * Persists the member order used by weekly tables. Members can be sorted once, then fine-tuned by + * dragging the handle or through the accessibility "move up/down" actions; every change saves. + */ class MemberOrderActivity : BaseActivity() { - private lateinit var listContainer: LinearLayout - private lateinit var scrollView: ScrollView + override val legacyStyling = false + + private lateinit var profileBinding: ActivePlatoonScopeBinding + private lateinit var repository: PlatoonRepository private lateinit var preferences: MemberOrderPreferences - private lateinit var latestMembers: Map + private val work = BackgroundWork(this, "GF2MemberOrder") + private val adapter = OrderAdapter() + private val stateAdapter = SingleViewAdapter() + private lateinit var touchHelper: ItemTouchHelper private var members = mutableListOf() - private var defaultMembers = emptyList() - private var draggedUid: Long? = null - private var dragCommitted = false + private var latestMembers = emptyMap() + private var sortField = MemberSortField.JOIN_DATE + private var sortDirection = MemberSortDirection.ASCENDING + private lateinit var fieldButton: TextView + private lateinit var directionButton: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - preferences = MemberOrderPreferences(this) - val repository = PlatoonRepository(this) - defaultMembers = MemberOrderSorter.sort( - members = repository.listMemberStatuses(activeOnly = true), - latestMembers = emptyMap(), - field = MemberSortField.JOIN_DATE, - direction = MemberSortDirection.ASCENDING, - ) - latestMembers = repository.listSnapshots(limit = 1) - .firstOrNull() - ?.members - .orEmpty() - .associateBy(SnapshotMember::uid) - members = initialOrder(defaultMembers).toMutableList() - setContentView(buildContentView()) + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope ?: run { + finish() + return + } + repository = PlatoonRepository(this, scope) + preferences = MemberOrderPreferences(this, scope.storageId) + val screen = Screen(this) + screen.topBar + .setNavigation(::finish) + .setTitle(getString(R.string.member_order)) + .setActions( + emptyList(), + overflow = listOf(MenuEntry(getString(R.string.reset_member_order)) { confirmReset() }), + ) + val list = RecyclerView(this).apply { + id = R.id.screen_list + layoutManager = LinearLayoutManager(context) + adapter = ConcatAdapter(SingleViewAdapter(buildHeader()), this@MemberOrderActivity.adapter, stateAdapter) + setPadding(dp(16), 0, dp(16), dp(24)) + } + touchHelper = ItemTouchHelper(DragCallback()).apply { attachToRecyclerView(list) } + screen.setBody(list) + stateAdapter.view = StateViews.loading(this, getString(R.string.loading_members)) + load() } - private fun initialOrder(active: List): List = - if (preferences.read().isNotEmpty()) { - preferences.apply(active, MemberStatus::uid) - } else { - active + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) finish() + } + + private fun buildHeader(): View = column { + addView(text(getString(R.string.member_order_description), TextRole.SECONDARY), lp(top = 4)) + fieldButton = choiceChip { chooseField() } + directionButton = choiceChip { toggleDirection() } + addView(panel(padding = 12) { + addView(text(getString(R.string.sort_once), TextRole.LABEL), lp()) + addView(row { + addView(fieldButton, lp(width = 0, height = 48, weight = 1.4f)) + addView(directionButton, lp(width = 0, height = 48, weight = 1f, start = 8)) + }, lp(top = 8)) + addView(button(getString(R.string.apply_sort), ModernUi.ControlRole.SECONDARY) { applySort() }, lp(top = 8)) + }, lp(top = 12, bottom = 8)) + updateSortLabels() + } + + private fun choiceChip(onClick: () -> Unit): TextView = text(null, TextRole.BODY) { + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), 0, dp(8), 0) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + background = android.graphics.drawable.GradientDrawable().apply { + setColor(getColor(R.color.surface_variant)) + cornerRadius = dpf(12f) } + setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_expand_more, 0) + compoundDrawableTintList = android.content.res.ColorStateList.valueOf(getColor(R.color.text_secondary)) + isClickable = true + isFocusable = true + setOnClickListener { onClick() } + } - private fun buildContentView(): ScrollView { - val sortField = Spinner(this).apply { - adapter = ArrayAdapter( - this@MemberOrderActivity, - android.R.layout.simple_spinner_dropdown_item, - listOf( - getString(R.string.sort_join_date), - getString(R.string.sort_weekly_merit), - getString(R.string.sort_total_merit), - ), + private fun load() { + work.run( + 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) + }, + ) { result -> + result.fold( + onSuccess = { (ordered, latest) -> + members = ordered.toMutableList() + latestMembers = latest + stateAdapter.view = if (members.isEmpty()) { + StateViews.empty(this, R.drawable.ic_group, getString(R.string.no_active_members)) + } else { + null + } + adapter.notifyDataSetChanged() + }, + onFailure = { stateAdapter.view = StateViews.failure(this, getString(R.string.platoon_load_failed)) { load() } }, ) } - val sortDirection = Spinner(this).apply { - adapter = ArrayAdapter( - this@MemberOrderActivity, - android.R.layout.simple_spinner_dropdown_item, - listOf( - getString(R.string.ascending), - getString(R.string.descending), - ), - ) + } + + private fun chooseField() { + val fields = MemberSortField.entries + Dialogs.choose(this, getString(R.string.sort_criterion), fields.map { getString(fieldLabel(it)) }, sortField.ordinal) { + sortField = fields[it] + updateSortLabels() } - val body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(16), dp(16), dp(16)) - addView(TextView(context).apply { - text = getString(R.string.member_order) - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.member_order_description) - setPadding(0, dp(6), 0, dp(10)) - }, matchWidth()) - addView(sortControlRow(R.string.sort_criterion, sortField), matchWidth()) - addView(sortControlRow(R.string.sort_direction, sortDirection), matchWidth()) - addView(Button(context).apply { - text = getString(R.string.apply_sort) - usePrimaryActionStyle() - setOnClickListener { - members = MemberOrderSorter.sort( - members = members, - latestMembers = latestMembers, - field = MemberSortField.entries[sortField.selectedItemPosition], - direction = MemberSortDirection.entries[sortDirection.selectedItemPosition], - ).toMutableList() - saveAndRender() - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.reset_member_order) - useTertiaryActionStyle() - setOnClickListener { - preferences.clear() - members = defaultMembers.toMutableList() - render() - } - }, matchWidth()) - listContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - layoutTransition = LayoutTransition().apply { - setDuration(DRAG_TRANSITION_MILLIS) - } - setOnDragListener(::handleListDrag) - } - addView(listContainer, matchWidth()) + } + + private fun toggleDirection() { + sortDirection = if (sortDirection == MemberSortDirection.ASCENDING) { + MemberSortDirection.DESCENDING + } else { + MemberSortDirection.ASCENDING } - scrollView = ScrollView(this).apply { addView(body, matchWidth()) } - render() - return scrollView + updateSortLabels() } - private fun sortControlRow(label: Int, spinner: Spinner) = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(TextView(context).apply { - text = getString(label) - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - addView(spinner, LinearLayout.LayoutParams(0, wrap(), 2f)) + private fun updateSortLabels() { + fieldButton.text = getString(fieldLabel(sortField)) + fieldButton.contentDescription = getString(R.string.sort_criterion) + ": " + fieldButton.text + directionButton.text = getString( + if (sortDirection == MemberSortDirection.ASCENDING) R.string.ascending else R.string.descending, + ) + directionButton.contentDescription = getString(R.string.sort_direction) + ": " + directionButton.text } - private fun render() { - if (!::listContainer.isInitialized) return - listContainer.removeAllViews() - members.forEachIndexed { index, member -> - val row = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, dp(2), 0, dp(2)) - tag = member.uid - addView(TextView(context).apply { - text = "${index + 1}. ${member.name}\n#${member.uid}" - setPadding(dp(8), dp(6), dp(8), dp(6)) - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - } - row.addView(ImageView(this).apply { - setImageResource(R.drawable.ic_drag_handle) - setPadding(dp(14), dp(14), dp(14), dp(14)) - contentDescription = getString(R.string.drag_member, member.name) - isFocusable = true - setOnLongClickListener { - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - startDragAndDrop( - ClipData.newPlainText("member_uid", member.uid.toString()), - View.DragShadowBuilder(row), - member.uid, - 0, - ) - true - } - }, LinearLayout.LayoutParams(dp(52), dp(52))) - listContainer.addView(row, matchWidth()) - } - draggedUid?.let(::highlightDraggedRow) + private fun fieldLabel(field: MemberSortField) = when (field) { + MemberSortField.JOIN_DATE -> R.string.sort_join_date + MemberSortField.WEEKLY_MERIT -> R.string.sort_weekly_merit + MemberSortField.TOTAL_MERIT -> R.string.sort_total_merit } - private fun handleListDrag(view: View, event: DragEvent): Boolean { - if (event.localState !is Long) return false - when (event.action) { - DragEvent.ACTION_DRAG_STARTED -> { - draggedUid = event.localState as Long - dragCommitted = false - highlightDraggedRow(draggedUid ?: return false) - } - DragEvent.ACTION_DRAG_LOCATION -> { - autoScroll(event.y) - previewDraggedMember(event.localState as Long, event.y) - } - DragEvent.ACTION_DROP -> { - dragCommitted = true - preferences.write(members.map(MemberStatus::uid)) - draggedUid = null - render() - } - DragEvent.ACTION_DRAG_ENDED -> { - if (!dragCommitted) { - preferences.write(members.map(MemberStatus::uid)) - } - draggedUid = null - render() + private fun applySort() { + members = MemberOrderSorter.sort(members, latestMembers, sortField, sortDirection).toMutableList() + adapter.notifyDataSetChanged() + persist() + } + + 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) + load() } } - return true } - private fun autoScroll(y: Float) { - val location = IntArray(2) - listContainer.getLocationOnScreen(location) - val screenY = location[1] + y - val scrollLocation = IntArray(2) - scrollView.getLocationOnScreen(scrollLocation) - val top = scrollLocation[1] - val bottom = top + scrollView.height - when { - screenY < top + dp(72) -> scrollView.scrollBy(0, -dp(24)) - screenY > bottom - dp(72) -> scrollView.scrollBy(0, dp(24)) - } + private fun move(from: Int, to: Int) { + if (from !in members.indices || to !in members.indices || from == to) return + members.add(to, members.removeAt(from)) + adapter.notifyItemMoved(from, to) + adapter.notifyItemRangeChanged(minOf(from, to), kotlin.math.abs(from - to) + 1, POSITION_PAYLOAD) } - private fun previewDraggedMember(uid: Long, y: Float) { - val from = members.indexOfFirst { it.uid == uid } - if (from < 0) return - var target = members.size - for (index in 0 until listContainer.childCount) { - val child = listContainer.getChildAt(index) - if (y < child.top + child.height / 2f) { - target = index - break - } + 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) } - if (target > from) target -= 1 - target = target.coerceIn(0, members.lastIndex) - if (target == from) return + } - val member = members.removeAt(from) - members.add(target, member) - val row = listContainer.getChildAt(from) - listContainer.removeViewAt(from) - listContainer.addView(row, target) - refreshVisiblePositions() - listContainer.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) - highlightDraggedRow(uid) + private inner class OrderAdapter : RecyclerView.Adapter() { + override fun getItemCount() = members.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = OrderHolder() + override fun onBindViewHolder(holder: OrderHolder, position: Int) = holder.bind(members[position], position) } - private fun refreshVisiblePositions() { - members.forEachIndexed { index, member -> - val row = listContainer.getChildAt(index) as? LinearLayout ?: return@forEachIndexed - val label = row.getChildAt(0) as? TextView ?: return@forEachIndexed - label.text = "${index + 1}. ${member.name}\n#${member.uid}" + private inner class OrderHolder : RecyclerView.ViewHolder(LinearLayout(this@MemberOrderActivity)) { + private val position = text(null, TextRole.LABEL) { + gravity = Gravity.CENTER + minWidth = dp(32) + } + private val name = text(null, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + private val detail = text(null, TextRole.CAPTION) + private val handle = icon(R.drawable.ic_drag_handle).apply { + scaleType = ImageView.ScaleType.CENTER } - } - private fun highlightDraggedRow(uid: Long) { - for (index in 0 until listContainer.childCount) { - val row = listContainer.getChildAt(index) - val selected = row.tag == uid - row.elevation = if (selected) dp(5).toFloat() else 0f - row.background = if (selected) { - GradientDrawable().apply { - setColor(getColor(R.color.accent_surface)) - setStroke(dp(2), getColor(R.color.accent)) - cornerRadius = dp(2).toFloat() + @SuppressLint("ClickableViewAccessibility") + private fun install() { + handle.setOnTouchListener { view, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + touchHelper.startDrag(this) } - } else { - null + false } } - } - private fun saveAndRender() { - preferences.write(members.map(MemberStatus::uid)) - render() + init { + (itemView as LinearLayout).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(60) + setPadding(dp(4), dp(6), 0, dp(6)) + background = android.graphics.drawable.GradientDrawable().apply { + setColor(getColor(R.color.surface)) + cornerRadius = dpf(12f) + } + layoutParams = RecyclerView.LayoutParams(MATCH, WRAP).apply { bottomMargin = dp(4) } + addView(position, lp(WRAP, WRAP, end = 8)) + addView(column { + addView(name, lp()) + addView(detail, lp()) + }, lp(width = 0, weight = 1f)) + addView(handle, lp(48, 48)) + } + install() + } + + fun bind(member: MemberStatus, index: Int) { + position.text = (index + 1).toString() + name.text = member.name + detail.text = "#${member.uid}" + itemView.contentDescription = getString(R.string.member_order_position, index + 1, members.size, member.name) + handle.contentDescription = getString(R.string.drag_member, member.name) + ViewCompat.removeAccessibilityAction(itemView, R.id.action_move_up) + ViewCompat.removeAccessibilityAction(itemView, R.id.action_move_down) + if (index > 0) { + ViewCompat.replaceAccessibilityAction( + itemView, + AccessibilityNodeInfoCompat.AccessibilityActionCompat(R.id.action_move_up, getString(R.string.move_up)), + getString(R.string.move_up), + ) { _, _ -> move(bindingAdapterPosition, bindingAdapterPosition - 1).also { persist() }; true } + } + if (index < members.lastIndex) { + ViewCompat.replaceAccessibilityAction( + itemView, + AccessibilityNodeInfoCompat.AccessibilityActionCompat(R.id.action_move_down, getString(R.string.move_down)), + getString(R.string.move_down), + ) { _, _ -> move(bindingAdapterPosition, bindingAdapterPosition + 1).also { persist() }; true } + } + } } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - private fun wrap() = ViewGroup.LayoutParams.WRAP_CONTENT - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + private inner class DragCallback : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) { + override fun isLongPressDragEnabled() = true + + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int = + if (viewHolder is OrderHolder) super.getMovementFlags(recyclerView, viewHolder) else 0 + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean { + if (target !is OrderHolder) return false + move(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) + recyclerView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + viewHolder?.itemView?.background?.setTint(getColor(R.color.accent_surface)) + } + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + viewHolder.itemView.background?.setTint(getColor(R.color.surface)) + persist() + } + } private companion object { - const val DRAG_TRANSITION_MILLIS = 120L + val POSITION_PAYLOAD = Any() } } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index 6b80e9a..467ca40 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -1,66 +1,134 @@ package dev.gf2log.app -import android.app.AlertDialog import android.content.Intent -import android.graphics.Typeface +import android.content.res.ColorStateList import android.os.Bundle import android.text.Editable import android.text.InputType +import android.text.TextUtils import android.text.TextWatcher -import android.util.Log +import android.view.Gravity +import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.Button import android.widget.CheckBox import android.widget.EditText import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.Spinner import android.widget.TextView -import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView import dev.gf2log.app.management.MemberStatus -import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonMemberCsv +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.PlatoonSnapshot import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.settings.GameTimeZonePreferences -import java.time.format.DateTimeFormatter -import java.util.concurrent.Executors +import dev.gf2log.app.ui.AccessibleRow +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.MATCH +import dev.gf2log.app.ui.MenuEntry +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.SingleViewAdapter +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +/** Platoon roster: search, filter, and sort members; long-press starts selection for export. */ class PlatoonActivity : BaseActivity() { - private lateinit var repository: PlatoonRepository + override val legacyStyling = false + private lateinit var profileBinding: ActivePlatoonScopeBinding - private lateinit var summary: TextView - private lateinit var memberContainer: LinearLayout + private lateinit var repository: PlatoonRepository + private lateinit var screen: Screen + private val work = BackgroundWork(this, "GF2Platoon") + private val memberAdapter = MemberAdapter() + private val stateAdapter = SingleViewAdapter() + private lateinit var summary: LinearLayout private lateinit var searchInput: EditText - private lateinit var filterSpinner: Spinner - private lateinit var sortSpinner: Spinner - private var statuses = emptyList() - private var latestMembers = emptyMap() - private val selectedUids = linkedSetOf() - private var pendingMemberCsv: String? = null - private val reconciliationExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2PlatoonReconciliation") + private lateinit var sortButton: TextView + private lateinit var resultCount: TextView + + private var model: Model? = null + private var filter = Filter.ACTIVE + private var sort = Sort.NAME + private var selecting = false + private val selected = linkedSetOf() + private var pendingExport: LongArray? = null + + private val exportMembers = registerForActivityResult( + ActivityResultContracts.CreateDocument("text/csv"), + ) { uri -> + val uids = pendingExport?.toSet() + pendingExport = null + if (uri == null || uids.isNullOrEmpty()) 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, GameTimeZonePreferences.get(this)) + DocumentAccess.openOutput(contentResolver, uri).writer(Charsets.UTF_8).use { it.write(csv) } + }, + ) { result -> + Feedback.show(this, if (result.isSuccess) R.string.members_exported else R.string.status_export_failed) + if (result.isSuccess) setSelecting(false) + } + } + + private val exitSelection = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() = setSelecting(false) } - private var reconciliationGeneration = 0 - private var screenResumed = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) profileBinding = ActivePlatoonScopeBinding(this) val scope = profileBinding.scope ?: run { - TransientMessage.show(this, R.string.no_platoon_detected_detail, Toast.LENGTH_LONG) + Feedback.show(this, R.string.no_platoon_detected_detail, long = true) finish() return } repository = PlatoonRepository(this, scope) - setContentView( - PrimaryNavigation.wrap( - this, - buildContentView(), - PrimaryNavigation.Destination.PLATOON, - ), - ) + savedInstanceState?.let { state -> + filter = Filter.entries.getOrElse(state.getInt(STATE_FILTER)) { Filter.ACTIVE } + sort = Sort.entries.getOrElse(state.getInt(STATE_SORT)) { Sort.NAME } + selecting = state.getBoolean(STATE_SELECTING) + state.getLongArray(STATE_SELECTED)?.let { selected += it.toList() } + pendingExport = state.getLongArray(STATE_PENDING_EXPORT) + } + onBackPressedDispatcher.addCallback(this, exitSelection) + screen = Screen(this, PrimaryNavigation.Destination.PLATOON) + val header = buildHeader(savedInstanceState?.getString(STATE_QUERY).orEmpty()) + screen.setBody(RecyclerView(this).apply { + id = R.id.screen_list + layoutManager = LinearLayoutManager(context) + adapter = ConcatAdapter(SingleViewAdapter(header), memberAdapter, stateAdapter) + itemAnimator = null + setPadding(dp(16), 0, dp(16), dp(24)) + }) + setSelecting(selecting) } override fun onScreenResumed() { @@ -68,384 +136,434 @@ class PlatoonActivity : BaseActivity() { recreate() return } - screenResumed = true - val generation = ++reconciliationGeneration - reconciliationExecutor.execute { - val reconciliation = runCatching { repository.reconcileRetainedCsvFiles() } - reconciliation.exceptionOrNull()?.let { error -> - Log.e(TAG, "Retained Platoon CSV reconciliation failed", error) - } - runOnUiThread { - if (generation == reconciliationGeneration && screenResumed && - !isFinishing && !isDestroyed - ) { - if (reconciliation.isSuccess) { - refresh() - } else { - summary.setText(R.string.status_platoon_csv_import_failed) - } - } - } - } - } - - override fun onPause() { - screenResumed = false - reconciliationGeneration += 1 - super.onPause() + load() } - override fun onDestroy() { - reconciliationExecutor.shutdownNow() - super.onDestroy() + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt(STATE_FILTER, filter.ordinal) + outState.putInt(STATE_SORT, sort.ordinal) + outState.putBoolean(STATE_SELECTING, selecting) + outState.putLongArray(STATE_SELECTED, selected.toLongArray()) + if (::searchInput.isInitialized) outState.putString(STATE_QUERY, searchInput.text.toString()) + pendingExport?.let { outState.putLongArray(STATE_PENDING_EXPORT, it) } + super.onSaveInstanceState(outState) } - private fun buildContentView(): ScrollView { - val spacing = dp(16) - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(spacing, spacing, spacing, spacing) - addView(TextView(context).apply { - text = getString(R.string.platoon_management) - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - addView( - PlatoonProfileSelector.controls(this@PlatoonActivity), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { - topMargin = dp(8) - bottomMargin = dp(4) + private fun load() { + if (model == null) stateAdapter.view = StateViews.loading(this, getString(R.string.loading_members)) + work.run( + key = LOAD, + task = { + repository.reconcileRetainedCsvFiles() + val latest = repository.listSnapshots(1).firstOrNull() + Model( + latest = latest, + statuses = repository.listMemberStatuses(), + latestMembers = latestMembers(latest), + zone = GameTimeZonePreferences.get(this), + ) + }, + ) { result -> + result.fold( + onSuccess = { loaded -> + model = loaded + selected.retainAll(loaded.statuses.mapTo(mutableSetOf(), MemberStatus::uid)) + renderSummary(loaded) + renderMembers() + }, + onFailure = { + memberAdapter.submitList(emptyList()) + stateAdapter.view = StateViews.failure(this, getString(R.string.platoon_load_failed)) { load() } }, ) - summary = TextView(context).apply { - textSize = 14f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(6), 0, dp(8)) - } - addView(summary, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.weekly_table) - useFeatureActionStyle() - setOnClickListener { - startActivity(Intent(this@PlatoonActivity, WeeklyReportActivity::class.java)) - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.compare_latest_snapshots) - useNavigationActionStyle() - setOnClickListener { - startActivity( - Intent(this@PlatoonActivity, SnapshotComparisonActivity::class.java), - ) - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.export_selected_members) - useNavigationActionStyle() - setOnClickListener { exportSelectedMembers() } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.add_withdrawn_member) - useNavigationActionStyle() - setOnClickListener { showAddWithdrawnMemberDialog() } - }, matchWidth()) - searchInput = EditText(context).apply { - hint = getString(R.string.search_members) - setSingleLine(true) - addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - renderMembers() - } - override fun afterTextChanged(s: Editable?) = Unit - }) - } - addView(searchInput, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { - topMargin = dp(8) - bottomMargin = dp(8) + } + } + + private fun buildHeader(query: String): View { + searchInput = textField(getString(R.string.search_members), id = R.id.platoon_search_input).apply { + setText(query) + setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_search, 0, 0, 0) + compoundDrawablePadding = dp(8) + compoundDrawableTintList = ColorStateList.valueOf(getColor(R.color.text_secondary)) + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = renderMembers() + override fun afterTextChanged(s: Editable?) = Unit }) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - filterSpinner = spinner( - listOf( - getString(R.string.filter_active), - getString(R.string.filter_all), - ), - ) - sortSpinner = spinner( - listOf( - getString(R.string.sort_name), - getString(R.string.sort_merit), - getString(R.string.sort_total_merit_management), - getString(R.string.sort_last_login), - ), - ) - addView(filterSpinner, LinearLayout.LayoutParams(0, wrap(), 1f).apply { - marginEnd = dp(4) - }) - addView(sortSpinner, LinearLayout.LayoutParams(0, wrap(), 1f).apply { - marginStart = dp(4) - }) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(8) }) - filterSpinner.onItemSelectedListener = SimpleItemSelectedListener { renderMembers() } - sortSpinner.onItemSelectedListener = SimpleItemSelectedListener { renderMembers() } - memberContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - } - addView(memberContainer, matchWidth()) } - return ScrollView(this).apply { addView(container, matchWidth()) } + sortButton = text(null, TextRole.LABEL) { + setTextColor(getColor(R.color.text_primary)) + gravity = Gravity.CENTER_VERTICAL + minHeight = dp(48) + setPadding(dp(8), 0, dp(8), 0) + background = selectableRect() + setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_sort, 0, 0, 0) + compoundDrawablePadding = dp(6) + compoundDrawableTintList = ColorStateList.valueOf(getColor(R.color.text_secondary)) + setOnClickListener { chooseSort() } + } + resultCount = text(null, TextRole.LABEL) { + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + } + summary = row(Gravity.TOP) + updateSortLabel() + return column { + addView(PlatoonProfileSelector.card(this@PlatoonActivity), lp(top = 4)) + addView(summary, lp(top = 12)) + addView(searchInput, lp(top = 16)) + addView(ModernUi.segmentedControl( + context = this@PlatoonActivity, + options = Filter.entries.map { it.name to getString(it.label) }, + selectedValue = filter.name, + ) { value -> + filter = Filter.valueOf(value) + renderMembers() + }, lp(top = 8)) + addView(row { + addView(resultCount, lp(width = 0, weight = 1f, start = 4)) + addView(sortButton, lp(WRAP, WRAP)) + }, lp(top = 4, bottom = 4)) + } } - private fun refresh() { - val snapshots = repository.listSnapshots(1) - val latest = snapshots.firstOrNull() - statuses = repository.listMemberStatuses() - selectedUids.retainAll(statuses.mapTo(mutableSetOf(), MemberStatus::uid)) - latestMembers = latest?.members.orEmpty().associateBy(SnapshotMember::uid) - val active = statuses.count(MemberStatus::isActive) - val departed = statuses.size - active - summary.text = if (latest == null) { - getString(R.string.no_platoon_snapshot) - } else { - val zone = GameTimeZonePreferences.get(this@PlatoonActivity) - getString( - R.string.platoon_summary, - active, - departed, - DISPLAY_TIME.format(latest.capturedAt.atZone(zone)), - zone.id, - ) + private fun renderSummary(loaded: Model) { + summary.removeAllViews() + val active = loaded.statuses.count(MemberStatus::isActive) + val tiles = listOf( + getString(R.string.stat_active) to active.toString(), + getString(R.string.stat_withdrawn) to (loaded.statuses.size - active).toString(), + getString(R.string.stat_last_snapshot) to (loaded.latest?.let { + Labels.format(Labels.DATE_TIME, it.capturedAt, loaded.zone) + } ?: getString(R.string.none)), + ) + tiles.forEachIndexed { index, (label, value) -> + summary.addView(panel(padding = 12) { + isFocusable = true + contentDescription = "$label: $value" + addView(text(label, TextRole.CAPTION) { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, lp()) + addView(text(value, if (index < 2) TextRole.HEADLINE else TextRole.BODY) { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + maxLines = 2 + }, lp(top = 2)) + }, lp(width = 0, height = MATCH, weight = if (index < 2) 1f else 1.6f, start = if (index == 0) 0 else 8)) } - renderMembers() } private fun renderMembers() { - if (!::memberContainer.isInitialized) return + val loaded = model ?: return val query = searchInput.text.toString().trim() - val filtered = statuses.filter { status -> - val matchesFilter = when (filterSpinner.selectedItemPosition) { - 0 -> status.isActive - else -> true + val visible = loaded.statuses + .filter { status -> + when (filter) { + Filter.ACTIVE -> status.isActive + Filter.WITHDRAWN -> !status.isActive + Filter.ALL -> true + } } - val matchesQuery = query.isBlank() || - status.name.contains(query, ignoreCase = true) || - status.uid.toString().contains(query) - matchesFilter && matchesQuery - } - val sorted = when (sortSpinner.selectedItemPosition) { - 1 -> filtered.sortedByDescending { latestMembers[it.uid]?.weeklyMerit ?: -1 } - 2 -> filtered.sortedByDescending { latestMembers[it.uid]?.totalMerit ?: -1 } - 3 -> filtered.sortedByDescending { latestMembers[it.uid]?.lastLogin ?: 0 } - else -> filtered.sortedWith( - compareByDescending(MemberStatus::isActive) - .thenBy(String.CASE_INSENSITIVE_ORDER, MemberStatus::name), + .filter { query.isEmpty() || it.name.contains(query, ignoreCase = true) || it.uid.toString().contains(query) } + .let { sorted(it, loaded) } + resultCount.text = resources.getQuantityString(R.plurals.member_count, visible.size, visible.size) + stateAdapter.view = when { + visible.isNotEmpty() -> null + loaded.statuses.isEmpty() -> StateViews.empty( + this, + R.drawable.ic_group, + getString(R.string.no_platoon_snapshot), + getString(R.string.no_platoon_snapshot_detail), ) + else -> StateViews.empty(this, R.drawable.ic_search, getString(R.string.no_matching_members)) } + memberAdapter.submitList(visible) + } - memberContainer.removeAllViews() - if (sorted.isEmpty()) { - memberContainer.addView(TextView(this).apply { - text = getString(R.string.no_matching_members) - setPadding(0, dp(16), 0, dp(16)) - }, matchWidth()) - return + private fun sorted(statuses: List, loaded: Model): List = when (sort) { + Sort.NAME -> statuses.sortedWith( + compareByDescending(MemberStatus::isActive) + .thenBy(String.CASE_INSENSITIVE_ORDER, MemberStatus::name), + ) + Sort.WEEKLY_MERIT -> statuses.sortedByDescending { loaded.latestMembers[it.uid]?.weeklyMerit ?: -1 } + Sort.TOTAL_MERIT -> statuses.sortedByDescending { loaded.latestMembers[it.uid]?.totalMerit ?: -1 } + Sort.LAST_LOGIN -> statuses.sortedByDescending { loaded.latestMembers[it.uid]?.lastLogin ?: 0 } + } + + private fun chooseSort() { + Dialogs.choose(this, getString(R.string.sort_by), Sort.entries.map { getString(it.label) }, sort.ordinal) { + sort = Sort.entries[it] + updateSortLabel() + renderMembers() } - sorted.forEach { status -> - val latest = latestMembers[status.uid] - memberContainer.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(CheckBox(context).apply { - isChecked = status.uid in selectedUids - contentDescription = getString(R.string.select_member, status.name) - setOnCheckedChangeListener { _, checked -> - if (checked) selectedUids += status.uid else selectedUids -= status.uid - } - }, LinearLayout.LayoutParams(dp(48), ViewGroup.LayoutParams.MATCH_PARENT).apply { - marginEnd = dp(8) - }) - addView(ModernUi.actionRow( - context = context, - title = buildString { - append(status.name) - append(" · ") - append( - getString( - if (status.isActive) R.string.active_member else R.string.departed_member, - ), - ) - }, - detail = buildString { - append("#") - append(status.uid) - if (latest != null) { - append(" · ") - append(getString(R.string.merit_this_week)) - append(" ") - append(latest.weeklyMerit) - append(" · ") - append(getString(R.string.total_merit)) - append(" ") - append(latest.totalMerit) - } - }, - titleMaxLines = 2, - onClick = { - startActivity( - Intent(this@PlatoonActivity, MemberDetailActivity::class.java) - .putExtra(MemberDetailActivity.EXTRA_UID, status.uid), - ) - }, - ), LinearLayout.LayoutParams(0, wrap(), 1f)) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(8) }) + } + + private fun updateSortLabel() { + sortButton.text = getString(sort.label) + sortButton.contentDescription = getString(R.string.sort_by) + ": " + getString(sort.label) + } + + private fun setSelecting(enabled: Boolean) { + selecting = enabled + if (!enabled) selected.clear() + exitSelection.isEnabled = enabled + val bar = screen.topBar + if (enabled) { + bar.setNavigationIcon(R.drawable.ic_close, getString(R.string.cancel_selection)) + .setNavigation { setSelecting(false) } + .setTitle(resources.getQuantityString(R.plurals.selected_count, selected.size, selected.size)) + .setActions( + listOf( + BarAction(R.drawable.ic_check_circle, getString(R.string.select_all)) { selectAllVisible() }, + BarAction(R.drawable.ic_download, getString(R.string.export_selected_members), selected.isNotEmpty()) { + exportSelected() + }, + ), + ) + } else { + bar.setNavigation(null) + .setTitle(getString(R.string.platoon_management)) + .setActions( + listOf(BarAction(R.drawable.ic_compare, getString(R.string.compare_latest_snapshots)) { + startActivity(Intent(this, SnapshotComparisonActivity::class.java)) + }), + overflow = listOf( + MenuEntry(getString(R.string.select_members)) { setSelecting(true) }, + MenuEntry(getString(R.string.add_withdrawn_member)) { showAddWithdrawnMemberDialog() }, + MenuEntry(getString(R.string.edit_member_order)) { + startActivity(Intent(this, MemberOrderActivity::class.java)) + }, + ), + ) } + memberAdapter.notifyItemRangeChanged(0, memberAdapter.itemCount) } - @Deprecated("Uses the platform document picker without an AndroidX dependency") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode != REQUEST_EXPORT_MEMBERS) return - val content = pendingMemberCsv - pendingMemberCsv = null - val destination = data?.data - if (resultCode != RESULT_OK || destination == null || content == null) return - val exported = runCatching { - val output = dev.gf2log.app.ui.DocumentAccess.openOutput(contentResolver, destination) - ?: error("Document provider did not open an output stream") - output.writer(Charsets.UTF_8).use { it.write(content) } - }.isSuccess - TransientMessage.show( - this, - getString(if (exported) R.string.members_exported else R.string.status_export_failed), - Toast.LENGTH_SHORT, - ) + private fun toggle(uid: Long) { + if (!selected.add(uid)) selected.remove(uid) + setSelecting(true) } - @Suppress("DEPRECATION") - private fun exportSelectedMembers() { - val selected = statuses.filter { it.uid in selectedUids } + private fun selectAllVisible() { + val visible = memberAdapter.currentList.map(MemberStatus::uid) + if (selected.containsAll(visible)) selected.removeAll(visible.toSet()) else selected += visible + setSelecting(true) + } + + private fun exportSelected() { if (selected.isEmpty()) { - TransientMessage.show(this, R.string.select_members_first) + Feedback.show(this, R.string.select_members_first) return } - pendingMemberCsv = PlatoonMemberCsv.format( - statuses = selected, - latestMembers = latestMembers, - zoneId = GameTimeZonePreferences.get(this), - ) - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("text/csv") - .putExtra(Intent.EXTRA_TITLE, "GF2logger-members.csv") - startActivityForResult(intent, REQUEST_EXPORT_MEMBERS) + pendingExport = selected.toLongArray() + exportMembers.launch("GF2logger-members-${Labels.FILE_STAMP.format(LocalDateTime.now())}.csv") } private fun showAddWithdrawnMemberDialog() { - val uidInput = EditText(this).apply { - hint = getString(R.string.uid) - inputType = InputType.TYPE_CLASS_NUMBER - setSingleLine(true) - } - val nicknameInput = EditText(this).apply { - hint = getString(R.string.member_nickname) - setSingleLine(true) - } - val joined = DateTimePickerInput( + val zone = GameTimeZonePreferences.get(this) + val uid = LabeledField( this, - getString(R.string.join_field), - dateRequired = true, - zone = GameTimeZonePreferences.get(this), + getString(R.string.uid), + textField(getString(R.string.uid_hint), inputType = InputType.TYPE_CLASS_NUMBER), ) - val withdrew = DateTimePickerInput( + val nickname = LabeledField(this, getString(R.string.member_nickname), textField(getString(R.string.member_nickname))) + 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 note = LabeledField( this, - getString(R.string.withdraw_field), - dateRequired = true, - zone = GameTimeZonePreferences.get(this), + getString(R.string.membership_note_hint), + textField(getString(R.string.membership_note_hint), singleLine = false), ) - val noteInput = EditText(this).apply { - hint = getString(R.string.membership_note_hint) - minLines = 2 + val error = text(null, TextRole.CAPTION) { + setTextColor(getColor(R.color.destructive_action)) + visibility = View.GONE } - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), 0, dp(16), 0) - addView(uidInput, matchWidth()) - addView(nicknameInput, matchWidth()) - addView(joined, matchWidth()) - addView(withdrew, matchWidth()) - addView(noteInput, matchWidth()) + val content = Dialogs.content(this) { + listOf(uid, nickname, joined, withdrew, note, error).forEachIndexed { index, view -> + addView(view, lp(top = if (index == 0) 0 else 12)) + } } - val dialog = AlertDialog.Builder(this) - .setTitle(R.string.add_withdrawn_member) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.add, null) - .create() - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val uid = uidInput.text.toString().trim().toLongOrNull() - val nickname = nicknameInput.text.toString().trim() + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.add_withdrawn_member) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.add, null), + ) { dialog -> + dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> + val uidValue = uid.input.text.toString().trim().toLongOrNull()?.takeIf { it > 0 } + val name = nickname.input.text.toString().trim() val joinedBoundary = joined.boundary val withdrewBoundary = withdrew.boundary - val validRange = joinedBoundary != null && - withdrewBoundary != null && - isValidMembershipRange(joinedBoundary, withdrewBoundary) - val saved = uid != null && uid > 0 && nickname.isNotBlank() && validRange && - runCatching { + val problem = when { + uidValue == null -> R.string.invalid_uid + model?.statuses?.any { it.uid == uidValue } == true -> R.string.duplicate_uid + name.isEmpty() -> R.string.invalid_nickname + joinedBoundary == null || withdrewBoundary == null -> R.string.invalid_dates_required + !isValidMembershipRange(joinedBoundary, withdrewBoundary) -> R.string.invalid_date_order + else -> null + } + if (problem != null) { + error.text = getString(problem) + error.visibility = View.VISIBLE + error.announceForAccessibility(error.text) + return@setOnClickListener + } + button.isEnabled = false + work.run( + task = { repository.addWithdrawnMember( - uid, - nickname, + requireNotNull(uidValue), + name, requireNotNull(joinedBoundary), requireNotNull(withdrewBoundary), - noteInput.text.toString(), + note.input.text.toString(), ) - }.getOrDefault(false) - if (saved) { - dialog.dismiss() - refresh() - TransientMessage.show(this, R.string.saved) + }, + ) { result -> + button.isEnabled = true + if (result.getOrDefault(false)) { + dialog.dismiss() + Feedback.show(this, R.string.saved) + load() + } else { + error.text = getString(R.string.invalid_withdrawn_member) + error.visibility = View.VISIBLE + } + } + } + } + } + + private fun latestMembers(snapshot: PlatoonSnapshot?): Map = + snapshot?.members.orEmpty().associateBy(SnapshotMember::uid) + + private inner class MemberAdapter : ListAdapter(DIFF) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = MemberHolder() + + override fun onBindViewHolder(holder: MemberHolder, position: Int) = holder.bind(getItem(position)) + } + + private inner class MemberHolder : RecyclerView.ViewHolder(AccessibleRow(this@PlatoonActivity)) { + private val row = itemView as AccessibleRow + private val check = CheckBox(this@PlatoonActivity).apply { + isClickable = false + isFocusable = false + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + buttonTintList = ModernUi.controlTint(this@PlatoonActivity) + } + private val name = text(null, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + private val status = badge("") + private val detail = text(null, TextRole.CAPTION) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + private val merit = text(null, TextRole.SUBTITLE) { gravity = Gravity.END } + + init { + row.orientation = LinearLayout.HORIZONTAL + row.gravity = Gravity.CENTER_VERTICAL + row.minimumHeight = dp(64) + row.setPadding(dp(8), dp(8), dp(8), dp(8)) + row.background = selectableRect() + row.layoutParams = RecyclerView.LayoutParams(MATCH, WRAP).apply { bottomMargin = dp(2) } + row.addView(check, lp(WRAP, WRAP, end = 4)) + row.addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(row { + addView(name, lp(width = 0, weight = 1f)) + addView(status, lp(WRAP, WRAP, start = 8)) + }, lp()) + addView(detail, lp(top = 2)) + }, lp(width = 0, weight = 1f)) + row.addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(merit, lp()) + addView(text(getString(R.string.merit_this_week_short), TextRole.CAPTION) { gravity = Gravity.END }, lp()) + }, lp(WRAP, WRAP, start = 12)) + row.addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) + } + + fun bind(member: MemberStatus) { + val loaded = model ?: return + val latest = loaded.latestMembers[member.uid] + check.visibility = if (selecting) View.VISIBLE else View.GONE + check.isChecked = member.uid in selected + name.text = member.name + status.text = getString(if (member.isActive) R.string.active_member else R.string.departed_member) + status.background.setTint(getColor(if (member.isActive) R.color.success_surface else R.color.surface_variant)) + status.setTextColor(getColor(if (member.isActive) R.color.success_text else R.color.text_secondary)) + detail.text = buildList { + add("#${member.uid}") + add(getString(R.string.level_short, latest?.level ?: member.level)) + latest?.lastLogin?.takeIf { it > 0 }?.let { + add(getString( + R.string.last_login_short, + Labels.format(Labels.DATE_TIME, Instant.ofEpochSecond(it), loaded.zone), + )) + } + }.joinToString(" · ") + merit.text = latest?.weeklyMerit?.toString() ?: "—" + row.isSelected = selecting && member.uid in selected + row.contentDescription = listOfNotNull( + getString(R.string.selected).takeIf { row.isSelected }, + member.name, + status.text, + detail.text, + getString(R.string.merit_this_week) + " " + merit.text, + ).joinToString(". ") + row.setOnClickListener { + if (selecting) { + toggle(member.uid) } else { - TransientMessage.show( - this, - R.string.invalid_withdrawn_member, - Toast.LENGTH_SHORT, + startActivity( + Intent(this@PlatoonActivity, MemberDetailActivity::class.java) + .putExtra(MemberDetailActivity.EXTRA_UID, member.uid), ) } } + row.setOnLongClickListener { + toggle(member.uid) + true + } } - dialog.show() } - private fun spinner(items: List) = Spinner(this).apply { - adapter = ArrayAdapter( - this@PlatoonActivity, - android.R.layout.simple_spinner_dropdown_item, - items, - ) + private data class Model( + val latest: PlatoonSnapshot?, + val statuses: List, + val latestMembers: Map, + val zone: ZoneId, + ) + + private enum class Filter(val label: Int) { + ACTIVE(R.string.filter_active), + WITHDRAWN(R.string.filter_withdrawn), + ALL(R.string.filter_everyone), } - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - private fun wrap() = ViewGroup.LayoutParams.WRAP_CONTENT - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + private enum class Sort(val label: Int) { + NAME(R.string.sort_name), + WEEKLY_MERIT(R.string.sort_merit), + TOTAL_MERIT(R.string.sort_total_merit_management), + LAST_LOGIN(R.string.sort_last_login), + } + + private companion object { + const val LOAD = "load" + const val STATE_FILTER = "platoon.filter" + const val STATE_SORT = "platoon.sort" + const val STATE_SELECTING = "platoon.selecting" + const val STATE_SELECTED = "platoon.selected" + const val STATE_QUERY = "platoon.query" + const val STATE_PENDING_EXPORT = "platoon.pending_export" - companion object { - private const val TAG = "GF2Platoon" - private val DISPLAY_TIME = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") - private const val REQUEST_EXPORT_MEMBERS = 301 + val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MemberStatus, newItem: MemberStatus) = oldItem.uid == newItem.uid + override fun areContentsTheSame(oldItem: MemberStatus, newItem: MemberStatus) = oldItem == newItem + } } } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index 01efed9..d5aae83 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -2,414 +2,336 @@ package dev.gf2log.app import android.app.Activity import android.app.AlertDialog -import android.content.res.ColorStateList -import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable import android.text.Editable import android.text.InputType import android.text.TextUtils import android.text.TextWatcher import android.view.Gravity import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.EditText -import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout -import android.widget.RadioButton -import android.widget.RadioGroup -import android.widget.ScrollView -import android.widget.TextView import dev.gf2log.app.capture.CaptureStatus 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.settings.GameServerRegion +import dev.gf2log.app.ui.AccessibleRow +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.dpf +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.iconButton +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField +import java.lang.ref.WeakReference import java.util.concurrent.Executors /** Shared, presentation-only selector and manager for isolated Platoon scopes. */ internal object PlatoonProfileSelector { - fun controls( - activity: Activity, - compact: Boolean = false, - showManageButton: Boolean = true, - ): LinearLayout = - LinearLayout(activity).apply { + /** + * The active-Platoon card: emblem, name, and verified identity. Tapping it opens the selector; + * a profile switch recreates the hosting screen so every repository rebinds to the new scope. + */ + fun card(activity: Activity, compact: Boolean = false): View { + val profile = PlatoonProfileRegistry(activity).active() + return AccessibleRow(activity).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - addView( - selectorButton(activity, compact), - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - if (showManageButton) { - addView( - manageButton(activity), - LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { - marginStart = dp(activity, 8) - }, - ) - } - } - - private fun selectorButton(activity: Activity, compact: Boolean): Button { - val registry = PlatoonProfileRegistry(activity) - val activeProfile = registry.active() - return Button(activity).apply { - text = (activeProfile?.let { label(activity, it) } - ?: activity.getString(R.string.no_platoon_detected)) + " ▾" - contentDescription = activity.getString(R.string.select_platoon) - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - activeProfile?.let { profile -> - PlatoonBannerArtwork.drawable(context, profile)?.let { artwork -> - val iconSize = dp(activity, if (compact) 28 else 34) - artwork.setBounds(0, 0, iconSize, iconSize) - setCompoundDrawablesRelative(artwork, null, null, null) - compoundDrawablePadding = dp(activity, 8) - } - } - if (compact) { - textSize = 11f - maxWidth = dp(activity, 210) - setPadding(dp(activity, 10), 0, dp(activity, 10), 0) - useSelectorActionStyle() - } else { - useNavigationActionStyle() - } - setOnClickListener { showSelector(activity, registry) } + minimumHeight = activity.dp(if (compact) 56 else 64) + setPadding(activity.dp(12), activity.dp(8), activity.dp(8), activity.dp(8)) + background = outlinedSurface(activity) + contentDescription = activity.getString(R.string.select_platoon) + ". " + + (profile?.let { "${it.platoonName}. ${Labels.profileIdentity(activity, it)}" } + ?: activity.getString(R.string.no_platoon_detected)) + setOnClickListener { showSelector(activity) } + addView(emblem(activity, profile, if (compact) 36 else 44), activity.lp( + width = if (compact) 36 else 44, + height = if (compact) 36 else 44, + end = 12, + )) + addView(activity.column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(activity.text( + profile?.platoonName ?: activity.getString(R.string.no_platoon_detected), + if (compact) TextRole.SUBTITLE else TextRole.TITLE, + ) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, activity.lp()) + addView(activity.text( + profile?.let { Labels.profileIdentity(activity, it) } + ?: activity.getString(R.string.no_platoon_detected_detail), + TextRole.CAPTION, + ) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, activity.lp()) + }, activity.lp(width = 0, weight = 1f)) + addView(activity.icon(R.drawable.ic_expand_more), activity.lp(24, 24, start = 8)) } } - private fun manageButton(activity: Activity) = ImageButton(activity).apply { - setImageResource(R.drawable.ic_edit) - contentDescription = activity.getString(R.string.manage_platoon_profiles) - useModernIconStyle() - setPadding(dp(activity, 12), dp(activity, 12), dp(activity, 12), dp(activity, 12)) - setOnClickListener { showManagement(activity, PlatoonProfileRegistry(activity)) } - } - - private fun label(activity: Activity, profile: PlatoonProfile): String = - "${profile.client.displayName} / ${regionCode(profile.serverRegion)} / " + - "${profile.platoonName} / ${profile.platoonId}" + /** Transitional entry point for screens that still embed the selector inline. */ + fun controls(activity: Activity, compact: Boolean = false, showManageButton: Boolean = true): View = + card(activity, compact) - private fun showSelector(activity: Activity, registry: PlatoonProfileRegistry) { + fun showSelector(activity: Activity) { + val registry = PlatoonProfileRegistry(activity) val profiles = registry.list() if (profiles.isEmpty()) { - showNoPlatoonMessage(activity) + Feedback.show(activity, R.string.no_platoon_detected_detail, long = true) return } val activeId = registry.active()?.storageId - lateinit var selectorDialog: AlertDialog - val choices = RadioGroup(activity).apply { - orientation = RadioGroup.VERTICAL - setPadding(dp(activity, 12), dp(activity, 4), dp(activity, 12), dp(activity, 4)) + lateinit var dialog: AlertDialog + val content = Dialogs.content(activity) { + setPadding(activity.dp(12), activity.dp(4), activity.dp(12), activity.dp(4)) profiles.forEach { profile -> - addView(RadioButton(context).apply { - text = label(activity, profile) - textSize = 14f - gravity = Gravity.CENTER_VERTICAL - minimumHeight = dp(activity, 56) - isChecked = profile.storageId == activeId - setPadding(dp(activity, 8), dp(activity, 4), dp(activity, 8), dp(activity, 4)) - PlatoonBannerArtwork.drawable(context, profile)?.let { artwork -> - val iconSize = dp(activity, 40) - artwork.setBounds(0, 0, iconSize, iconSize) - setCompoundDrawablesRelative(artwork, null, null, null) - compoundDrawablePadding = dp(activity, 10) - } - setOnClickListener { - selectorDialog.dismiss() - if (profile.storageId != activeId && registry.setActive(profile.storageId)) { - activity.recreate() - } + addView(profileRow(activity, profile, selected = profile.storageId == activeId) { + dialog.dismiss() + if (profile.storageId != activeId && registry.setActive(profile.storageId)) { + activity.recreate() } - }, RadioGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) + }, activity.lp(top = 2, bottom = 2)) } } - selectorDialog = AlertDialog.Builder(activity) - .setTitle(R.string.select_platoon) - .setView(ScrollView(activity).apply { addView(choices) }) - .setNegativeButton(android.R.string.cancel, null) - .create() - selectorDialog.show() + dialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.select_platoon) + .setView(content) + .setNeutralButton(R.string.manage_platoon_profiles) { _, _ -> showManagement(activity) } + .setNegativeButton(R.string.cancel, null), + ) } - private fun showManagement(activity: Activity, registry: PlatoonProfileRegistry) { + fun showManagement(activity: Activity) { + val registry = PlatoonProfileRegistry(activity) val profiles = registry.list() if (profiles.isEmpty()) { - showNoPlatoonMessage(activity) + Feedback.show(activity, R.string.no_platoon_detected_detail, long = true) return } val activeId = registry.active()?.storageId - val list = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(activity, 12), dp(activity, 4), dp(activity, 12), dp(activity, 4)) + lateinit var dialog: AlertDialog + val content = Dialogs.content(activity) { + setPadding(activity.dp(12), 0, activity.dp(12), activity.dp(4)) + addView(activity.text(activity.getString(R.string.manage_platoon_profiles_detail), TextRole.SECONDARY) { + setPadding(activity.dp(12), 0, activity.dp(12), activity.dp(8)) + }, activity.lp()) profiles.forEachIndexed { index, profile -> - addView(profileManagementRow(activity, profile, profile.storageId == activeId)) - if (index != profiles.lastIndex) { - addView( - View(context).apply { - setBackgroundColor(context.getColor(R.color.outline)) - }, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - dp(activity, 1), - ).apply { - marginStart = dp(activity, 12) - marginEnd = dp(activity, 12) - }, - ) - } + if (index > 0) addView(activity.divider(insetStartDp = 64)) + addView(managementRow(activity, profile, profile.storageId == activeId) { dialog.dismiss() }, activity.lp()) } } - AlertDialog.Builder(activity) - .setTitle(R.string.manage_platoon_profiles) - .setMessage(R.string.manage_platoon_profiles_detail) - .setView(ScrollView(activity).apply { addView(list) }) - .setNegativeButton(android.R.string.cancel, null) - .show() + dialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.manage_platoon_profiles) + .setView(content) + .setPositiveButton(R.string.done, null), + ) + } + + private fun profileRow( + activity: Activity, + profile: PlatoonProfile, + selected: Boolean, + onClick: () -> Unit, + ): View = AccessibleRow(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = activity.dp(64) + setPadding(activity.dp(12), activity.dp(8), activity.dp(12), activity.dp(8)) + background = if (selected) outlinedSurface(activity, accent = true) else activity.selectableRect() + isSelected = selected + contentDescription = "${profile.platoonName}. ${Labels.profileIdentity(activity, profile)}" + + if (selected) ". " + activity.getString(R.string.active_platoon) else "" + setOnClickListener { onClick() } + addView(emblem(activity, profile, 40), activity.lp(40, 40, end = 12)) + addView(activity.column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(activity.text(profile.platoonName, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, activity.lp()) + addView(activity.text(Labels.profileIdentity(activity, profile), TextRole.CAPTION), activity.lp()) + }, activity.lp(width = 0, weight = 1f)) + if (selected) addView(activity.icon(R.drawable.ic_check, R.color.accent_text), activity.lp(24, 24, start = 8)) } - private fun profileManagementRow( + private fun managementRow( activity: Activity, profile: PlatoonProfile, active: Boolean, - ) = LinearLayout(activity).apply { + dismiss: () -> Unit, + ): View = LinearLayout(activity).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - minimumHeight = dp(activity, 64) - setPadding(dp(activity, 10), dp(activity, 8), dp(activity, 4), dp(activity, 8)) - PlatoonBannerArtwork.drawable(context, profile)?.let { artwork -> - addView(ImageView(context).apply { - setImageDrawable(artwork) - contentDescription = null - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(dp(activity, 44), dp(activity, 44)).apply { - marginEnd = dp(activity, 10) - }) - } - addView( - LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView(TextView(context).apply { - text = profile.platoonName - textSize = 15f - typeface = Typeface.create("sans-serif-medium", Typeface.BOLD) - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - }, matchWidth()) - addView(TextView(context).apply { - text = activity.getString( - R.string.profile_management_identity, - profile.client.displayName, - regionCode(profile.serverRegion), - profile.platoonId, - ) + if (active) " · ${activity.getString(R.string.active_platoon)}" else "" - textSize = 12f - setTextColor(context.getColor(R.color.text_secondary)) - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - }, matchWidth()) - }, - LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), - ) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_edit) - contentDescription = activity.getString( - R.string.edit_platoon_server_description, - profile.platoonName, - ) - useModernIconStyle() - setOnClickListener { chooseProfileRegion(activity, profile) } - }, LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { - marginStart = dp(activity, 4) - }) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_delete) - imageTintList = ColorStateList.valueOf(context.getColor(R.color.destructive_action)) - contentDescription = activity.getString( - R.string.delete_platoon_description, - profile.platoonName, - ) - background = ModernUi.panelBackground(context) - setPadding(dp(activity, 12), dp(activity, 12), dp(activity, 12), dp(activity, 12)) - setOnClickListener { confirmDeleteFirst(activity, profile) } - }, LinearLayout.LayoutParams(dp(activity, 48), dp(activity, 48)).apply { - marginStart = dp(activity, 4) - }) + minimumHeight = activity.dp(64) + setPadding(activity.dp(12), activity.dp(8), 0, activity.dp(8)) + addView(emblem(activity, profile, 40), activity.lp(40, 40, end = 12)) + addView(activity.column { + addView(activity.text(profile.platoonName, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, activity.lp()) + addView(activity.text( + Labels.profileIdentity(activity, profile) + + if (active) " · " + activity.getString(R.string.active_platoon) else "", + TextRole.CAPTION, + ), activity.lp()) + }, activity.lp(width = 0, weight = 1f)) + addView(activity.iconButton( + R.drawable.ic_edit, + activity.getString(R.string.edit_platoon_server_description, profile.platoonName), + ) { + dismiss() + chooseProfileRegion(activity, profile) + }, activity.lp(48, 48)) + addView(activity.iconButton( + R.drawable.ic_delete, + activity.getString(R.string.delete_platoon_description, profile.platoonName), + tint = R.color.destructive_action, + ) { + dismiss() + confirmDeleteFirst(activity, profile) + }, activity.lp(48, 48)) } private fun chooseProfileRegion(activity: Activity, profile: PlatoonProfile) { if (CaptureStatus.isRunning) { - TransientMessage.show(activity, R.string.stop_capture_before_profile_change, android.widget.Toast.LENGTH_LONG) + Feedback.show(activity, R.string.stop_capture_before_profile_change, long = true) return } val regions = ClientServerRegionPreferences.allowedFor(profile.client.packageName) - AlertDialog.Builder(activity) - .setTitle(R.string.edit_platoon_server) - .setMessage( - activity.getString( - R.string.edit_platoon_server_message, - profile.client.displayName, - profile.platoonName, - ), - ) - .setSingleChoiceItems( - regions.map { activity.serverRegionLabel(it) }.toTypedArray(), - regions.indexOf(profile.serverRegion), - ) { dialog, which -> - dialog.dismiss() - val selected = regions[which] - if (selected != profile.serverRegion) { - runMaintenance(activity, R.string.platoon_server_update_failed) { - PlatoonProfileAdministration(activity) - .changeServerRegion(profile.storageId, selected) + Dialogs.show( + Dialogs.builder(activity) + .setTitle(activity.getString(R.string.edit_platoon_server_title, profile.platoonName)) + .setSingleChoiceItems( + regions.map { Labels.region(activity, it) }.toTypedArray(), + regions.indexOf(profile.serverRegion), + ) { dialog, which -> + dialog.dismiss() + val selected = regions[which] + if (selected != profile.serverRegion) { + runMaintenance(activity, R.string.platoon_server_update_failed) { + PlatoonProfileAdministration(activity).changeServerRegion(profile.storageId, selected) + } } } - } - .setNegativeButton(android.R.string.cancel, null) - .show() + .setNegativeButton(R.string.cancel, null), + ) } private fun confirmDeleteFirst(activity: Activity, profile: PlatoonProfile) { if (CaptureStatus.isRunning) { - TransientMessage.show(activity, R.string.stop_capture_before_profile_change, android.widget.Toast.LENGTH_LONG) + Feedback.show(activity, R.string.stop_capture_before_profile_change, long = true) return } - val warning = AlertDialog.Builder(activity) - .setTitle(R.string.delete_platoon_profile) - .setMessage(activity.getString(R.string.delete_platoon_first_warning, profile.platoonName)) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.delete, null) - .create() - warning.setOnShowListener { - warning.getButton(AlertDialog.BUTTON_POSITIVE).apply { - useDestructiveActionStyle() - setOnClickListener { - warning.dismiss() - confirmDeleteByName(activity, profile) - } - } - } - warning.show() + Dialogs.confirm( + activity, + activity.getString(R.string.delete_platoon_profile), + activity.getString(R.string.delete_platoon_first_warning, profile.platoonName), + activity.getString(R.string.continue_action), + destructive = true, + ) { confirmDeleteByName(activity, profile) } } private fun confirmDeleteByName(activity: Activity, profile: PlatoonProfile) { - val input = EditText(activity).apply { - hint = profile.platoonName - inputType = InputType.TYPE_CLASS_TEXT - maxLines = 1 - setSingleLine(true) - contentDescription = activity.getString(R.string.type_platoon_name) - } - val content = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(activity, 20), dp(activity, 4), dp(activity, 20), 0) - addView(TextView(context).apply { - text = activity.getString(R.string.delete_platoon_name_warning, profile.platoonName) - textSize = 14f - setTextColor(context.getColor(R.color.text_secondary)) - }, matchWidth()) - addView(input, matchWidth().apply { topMargin = dp(activity, 12) }) + val input = activity.textField(activity.getString(R.string.type_platoon_name), inputType = InputType.TYPE_CLASS_TEXT) + val content = Dialogs.content(activity) { + addView(activity.text( + activity.getString(R.string.delete_platoon_name_warning, profile.platoonName), + TextRole.SECONDARY, + ), activity.lp()) + addView(input, activity.lp(top = 12)) } - val dialog = AlertDialog.Builder(activity) - .setTitle(R.string.delete_platoon_profile) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.delete, null) - .create() - dialog.setOnShowListener { - val delete = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { - isEnabled = false - useDestructiveActionStyle() - setOnClickListener { - if (input.text.toString() != profile.platoonName) return@setOnClickListener - dialog.dismiss() - runMaintenance(activity, R.string.platoon_delete_failed) { - check(PlatoonProfileAdministration(activity).deleteProfile(profile.storageId)) { - "Unable to delete the Platoon profile" - } + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.delete_platoon_profile) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete, null), + destructive = true, + ) { dialog -> + val delete = dialog.getButton(AlertDialog.BUTTON_POSITIVE) + delete.isEnabled = false + delete.setOnClickListener { + if (input.text.toString() != profile.platoonName) return@setOnClickListener + dialog.dismiss() + runMaintenance(activity, R.string.platoon_delete_failed) { + check(PlatoonProfileAdministration(activity).deleteProfile(profile.storageId)) { + "Unable to delete the Platoon profile" } } } input.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged( - s: CharSequence?, - start: Int, - count: Int, - after: Int, - ) = Unit - - override fun onTextChanged( - s: CharSequence?, - start: Int, - before: Int, - count: Int, - ) { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { delete.isEnabled = s?.toString() == profile.platoonName } - override fun afterTextChanged(s: Editable?) = Unit }) } - dialog.show() } - private fun runMaintenance( - activity: Activity, - failureMessage: Int, - operation: () -> Unit, - ) { + /** + * Profile maintenance outlives a rotation: the result only refreshes the screen that is still + * showing, and any later screen rebinds itself through [ActivePlatoonScopeBinding]. + */ + private fun runMaintenance(activity: Activity, failureMessage: Int, operation: () -> Unit) { + val screen = WeakReference(activity) + val appContext = activity.applicationContext maintenanceExecutor.execute { val result = runCatching(operation) - activity.runOnUiThread { - if (activity.isFinishing || activity.isDestroyed) return@runOnUiThread - if (result.isSuccess) { - activity.recreate() - } else { - TransientMessage.show(activity, failureMessage, android.widget.Toast.LENGTH_LONG) - } + val current = screen.get() + if (current == null || current.isFinishing || current.isDestroyed) { + if (result.isFailure) Feedback.show(appContext, failureMessage, 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) } } } - private fun showNoPlatoonMessage(activity: Activity) { - TransientMessage.show(activity, R.string.no_platoon_detected_detail, android.widget.Toast.LENGTH_LONG) - } - - private fun Activity.serverRegionLabel(region: GameServerRegion): String = getString( - when (region) { - GameServerRegion.MANUAL -> R.string.server_region_manual - GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global - GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china - GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global - GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan - GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea - GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia - }, - ) - - private fun regionCode(region: GameServerRegion): String = when (region) { - GameServerRegion.MANUAL -> "Manual" - GameServerRegion.DARKWINTER_GLOBAL -> "GL" - GameServerRegion.DARKWINTER_CHINA -> "CN" - GameServerRegion.HAOPLAY_GLOBAL -> "GL" - GameServerRegion.HAOPLAY_JAPAN -> "JP" - GameServerRegion.HAOPLAY_KOREA -> "KR" - GameServerRegion.HAOPLAY_ASIA -> "ASIA" - } - - private fun matchWidth() = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + fun emblem(activity: Activity, profile: PlatoonProfile?, sizeDp: Int): View = + ImageView(activity).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + val artwork = profile?.let { PlatoonBannerArtwork.drawable(activity, it) } + if (artwork != null) { + setImageDrawable(artwork) + } else { + setImageResource(R.drawable.ic_group) + imageTintList = android.content.res.ColorStateList.valueOf(activity.getColor(R.color.text_secondary)) + scaleType = ImageView.ScaleType.CENTER + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(activity.getColor(R.color.surface_variant)) + } + } + minimumWidth = activity.dp(sizeDp) + minimumHeight = activity.dp(sizeDp) + } - private fun dp(activity: Activity, value: Int): Int = - (value * activity.resources.displayMetrics.density).toInt() + private fun outlinedSurface(activity: Activity, accent: Boolean = false) = + android.graphics.drawable.RippleDrawable( + android.content.res.ColorStateList.valueOf(activity.getColor(R.color.control_ripple)), + GradientDrawable().apply { + setColor(activity.getColor(if (accent) R.color.accent_surface else R.color.surface)) + cornerRadius = activity.dpf(16f) + setStroke(activity.dp(1), activity.getColor(if (accent) R.color.accent else R.color.outline)) + }, + null, + ) private val maintenanceExecutor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "GF2ProfileMaintenance") diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index c153824..ffbf722 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -2,108 +2,161 @@ package dev.gf2log.app import android.content.ClipData import android.content.ClipboardManager -import android.graphics.Typeface import android.os.Bundle -import android.view.ViewGroup -import android.widget.Button +import android.text.TextUtils +import android.view.View import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast +import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotComparison import dev.gf2log.app.management.SnapshotComparisonCsv -import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.settings.GameTimeZonePreferences -import java.time.format.DateTimeFormatter +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.text +import java.time.ZoneId +/** Joins, withdrawals, and counter changes between the two latest roster snapshots. */ class SnapshotComparisonActivity : BaseActivity() { + override val legacyStyling = false + + private lateinit var profileBinding: ActivePlatoonScopeBinding + private lateinit var screen: Screen + private val work = BackgroundWork(this, "GF2SnapshotComparison") + private val content by lazy { column() } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val snapshots = PlatoonRepository(this).listSnapshots(2) - setContentView(ScrollView(this).apply { - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(16), dp(16), dp(16)) - addView(heading(getString(R.string.snapshot_comparison), 24f)) - if (snapshots.size < 2) { - addView(text(getString(R.string.need_two_snapshots))) - } else { - val result = SnapshotComparison.compare(snapshots[1], snapshots[0]) - val zone = GameTimeZonePreferences.get(this@SnapshotComparisonActivity) - addView( - text( - getString( - R.string.comparison_period, - DISPLAY_TIME.format(result.older.capturedAt.atZone(zone)), - DISPLAY_TIME.format(result.newer.capturedAt.atZone(zone)), - ), - ), - ) - addView(Button(context).apply { - text = getString(R.string.copy_csv) - useTertiaryActionStyle() - setOnClickListener { - getSystemService(ClipboardManager::class.java).setPrimaryClip( - ClipData.newPlainText( - getString(R.string.snapshot_csv_clipboard_label), - SnapshotComparisonCsv.format(result), - ), - ) - TransientMessage.show( - this@SnapshotComparisonActivity, - R.string.snapshot_csv_copied, - Toast.LENGTH_SHORT, - ) - } - }) - addSection( - getString(R.string.joined_members), - result.joined.map { "${it.name} (#${it.uid})" }, - ) - addSection( - getString(R.string.departed_members), - result.left.map { "${it.name} (#${it.uid})" }, - ) - addSection( - getString(R.string.member_changes), - result.changes.map { - "${it.name} (#${it.uid}) " + - "${getString(R.string.merit_this_week)} +${it.weeklyMeritDelta}, " + - "${getString(R.string.total_merit)} +${it.totalMeritDelta}, " + - "${getString(R.string.gunsmoke_score)} +${it.totalScoreDelta}" - }, - ) - } - }, matchWidth()) - }) + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope ?: run { + finish() + return + } + screen = Screen(this) + screen.topBar.setNavigation(::finish).setTitle(getString(R.string.snapshot_comparison)) + screen.setBody(Screen.scrollingBody(this, content)) + content.addView(StateViews.loading(this, getString(R.string.loading_comparison))) + work.run( + task = { + val snapshots = PlatoonRepository(this, scope).listSnapshots(2) + snapshots.takeIf { it.size == 2 }?.let { SnapshotComparison.compare(it[1], it[0]) } to + GameTimeZonePreferences.get(this) + }, + ) { result -> + content.removeAllViews() + result.fold( + onSuccess = { (comparison, zone) -> render(comparison, zone) }, + onFailure = { content.addView(StateViews.failure(this, getString(R.string.platoon_load_failed)) { recreate() }) }, + ) + } } - private fun LinearLayout.addSection(title: String, lines: List) { - addView(heading(title, 20f)) - addView(text(lines.ifEmpty { listOf(getString(R.string.none)) }.joinToString("\n"))) + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) finish() } - private fun heading(value: String, size: Float) = TextView(this).apply { - text = value - textSize = size - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(8), 0, dp(4)) + private fun render(result: SnapshotComparison.Result?, zone: ZoneId) { + if (result == null) { + content.addView(StateViews.empty(this, R.drawable.ic_compare, getString(R.string.need_two_snapshots))) + return + } + screen.topBar.setActions(listOf(BarAction(R.drawable.ic_copy, getString(R.string.copy_csv)) { + getSystemService(ClipboardManager::class.java).setPrimaryClip( + ClipData.newPlainText(getString(R.string.snapshot_csv_clipboard_label), SnapshotComparisonCsv.format(result)), + ) + Feedback.show(this, R.string.snapshot_csv_copied) + })) + content.addView(panel { + addView(text(getString(R.string.comparison_window), TextRole.LABEL), lp()) + addView(text( + getString( + R.string.comparison_period, + Labels.format(Labels.DATE_TIME, result.older.capturedAt, zone), + Labels.format(Labels.DATE_TIME, result.newer.capturedAt, zone), + ), + TextRole.SUBTITLE, + ), lp(top = 2)) + addView(text( + getString(R.string.comparison_counts, result.joined.size, result.left.size, result.changes.size), + TextRole.CAPTION, + ), lp(top = 4)) + }, lp(top = 4)) + section(getString(R.string.joined_members), result.joined.size, result.joined.map { it.name to "#${it.uid}" }) + section(getString(R.string.departed_members), result.left.size, result.left.map { it.name to "#${it.uid}" }) + content.addView(sectionHeader(getString(R.string.member_changes_count, result.changes.size)), lp()) + if (result.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 -> + if (index > 0) addView(divider(insetStartDp = 8)) + addView(column { + setPadding(dp(8), dp(8), dp(8), dp(8)) + isFocusable = true + addView(row { + addView(text(change.name, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, lp(width = 0, weight = 1f)) + addView(text("#${change.uid}", TextRole.CAPTION), lp(WRAP, WRAP, start = 8)) + }, lp()) + addView(row { + listOf( + getString(R.string.merit_this_week_short) to change.weeklyMeritDelta, + getString(R.string.total_merit) to change.totalMeritDelta, + getString(R.string.gunsmoke_score) to change.totalScoreDelta, + ).forEachIndexed { badgeIndex, (label, delta) -> + addView(badge( + "$label +$delta", + if (delta > 0) R.color.accent_surface else R.color.surface_variant, + if (delta > 0) R.color.accent_text else R.color.text_secondary, + ), lp(WRAP, WRAP, start = if (badgeIndex == 0) 0 else 6)) + } + }, lp(top = 6)) + contentDescription = getString( + R.string.member_change_description, + change.name, + change.weeklyMeritDelta, + change.totalMeritDelta, + change.totalScoreDelta, + ) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + }, lp()) + } + }, lp()) } - private fun text(value: String) = TextView(this).apply { - text = value - textSize = 15f - setPadding(0, dp(4), 0, dp(8)) - setTextIsSelectable(true) + private fun section(title: String, count: Int, members: List>) { + content.addView(sectionHeader("$title · $count"), lp()) + if (members.isEmpty()) { + content.addView(text(getString(R.string.none), TextRole.SECONDARY), lp(start = 4)) + return + } + content.addView(panel(padding = 8) { + members.forEachIndexed { index, (name, uid) -> + if (index > 0) addView(divider(insetStartDp = 8)) + addView(row { + minimumHeight = dp(44) + setPadding(dp(8), 0, dp(8), 0) + addView(text(name, TextRole.BODY), lp(width = 0, weight = 1f)) + addView(text(uid, TextRole.CAPTION), lp(WRAP, WRAP)) + }, lp()) + } + }, lp()) } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - - companion object { - private val DISPLAY_TIME = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") - } } 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 fd4077e..730bb65 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt @@ -109,8 +109,14 @@ internal object Dialogs { internal object Feedback { private var active: Toast? = null - @Synchronized + private val main = android.os.Handler(android.os.Looper.getMainLooper()) + fun show(context: Context, message: CharSequence, long: Boolean = false) { + if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) { + val appContext = context.applicationContext + main.post { show(appContext, message, long) } + return + } active?.cancel() active = Toast.makeText( context.applicationContext, diff --git a/app/src/main/java/dev/gf2log/app/ui/SingleViewAdapter.kt b/app/src/main/java/dev/gf2log/app/ui/SingleViewAdapter.kt new file mode 100644 index 0000000..8ed2191 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/SingleViewAdapter.kt @@ -0,0 +1,44 @@ +package dev.gf2log.app.ui + +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView + +/** + * Hosts one pre-built view (a header, footer, or state message) inside a ConcatAdapter. The view + * is never recycled, so focused inputs inside it survive list updates. + */ +internal class SingleViewAdapter(view: View? = null) : RecyclerView.Adapter() { + var view: View? = view + set(value) { + if (field === value) return + val hadView = field != null + field = value + when { + hadView && value != null -> notifyItemChanged(0) + hadView -> notifyItemRemoved(0) + value != null -> notifyItemInserted(0) + } + } + + override fun getItemCount(): Int = if (view == null) 0 else 1 + + override fun getItemViewType(position: Int): Int = System.identityHashCode(this) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + object : RecyclerView.ViewHolder( + FrameLayout(parent.context).apply { + layoutParams = RecyclerView.LayoutParams(MATCH, WRAP) + }, + ) {}.apply { setIsRecyclable(false) } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val container = holder.itemView as FrameLayout + val content = view ?: return + if (content.parent === container) return + (content.parent as? ViewGroup)?.removeView(content) + container.removeAllViews() + container.addView(content, FrameLayout.LayoutParams(MATCH, WRAP)) + } +} diff --git a/app/src/main/res/drawable/ic_schedule.xml b/app/src/main/res/drawable/ic_schedule.xml new file mode 100644 index 0000000..6760b01 --- /dev/null +++ b/app/src/main/res/drawable/ic_schedule.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9f3b997..a1eae37 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -93,10 +93,10 @@ 이름 또는 UID 검색 가입 + 탈퇴 가입 - 정렬: 이름 - 정렬: 이번 주 공적 - 정렬: 누적 공적 - 정렬: 최근 접속 + 이름 + 이번 주 공적 + 누적 공적 + 최근 접속 아직 서클 멤버 스냅샷을 캡처하지 않았습니다. 가입 %1$d명 · 탈퇴 %2$d명\n최근 스냅샷: %3$s (%4$s) 일치하는 멤버가 없습니다 @@ -313,7 +313,7 @@ 설정 저장 0 이상의 올바른 기준값을 입력하세요. 멤버 순서 - 목록을 정렬하거나 멤버 옆 손잡이를 길게 눌러 끌어 놓으세요. 이후 주간 과업표에도 같은 순서가 유지됩니다. + 주간 표는 이 순서로 멤버를 표시합니다. 손잡이를 끌어 조정하면 자동으로 저장됩니다. 정렬 기준 정렬 방향 가입일 @@ -321,9 +321,9 @@ 누적 공적 오름차순 내림차순 - 정렬 적용 + 목록에 적용 멤버 순서 초기화 - %1$s 이동하려면 길게 누르기 + %1$s 끌어서 이동 멤버 순서 편집 CSV 복사 GF2logger 스냅샷 비교 CSV @@ -463,4 +463,68 @@ 더보기 %1$s · %2$s · ID %3$d 프로필 + 완료 + 계속 + %1$s의 서버 + 선택 취소 + 가입 %1$d · 탈퇴 %2$d · 변화 %3$d + 비교한 스냅샷 + 현재 + 날짜: %1$s + 시간: %1$s + 이 UID의 멤버가 이미 있습니다. + 게임 동향의 정확한 시각 · 수정 불가 + 전체 + 탈퇴 + 탈퇴는 가입 이후여야 합니다. + 가입일과 탈퇴일을 모두 선택하세요. + 닉네임을 입력하세요. + 양수 UID를 입력하세요. + 최근 접속 + 접속 %1$s + 레벨 + Lv. %1$d + 스냅샷을 비교하는 중… + 멤버를 불러오는 중… + %1$s: 이번 주 공적 +%2$d, 누적 공적 +%3$d, 흙먼지 점수 +%4$d + 수치 변화 · %1$d + 게임 닉네임 대신 모든 화면에 표시됩니다. + %2$d명 중 %1$d번째, %3$s + 멤버 순서를 초기화했습니다 + 이 기기에만 보관됩니다. 직접 포함하지 않으면 공유 이미지에 나타나지 않습니다. + 이 멤버에 대한 개인 메모 + 프로필 + %1$s → %2$s +가입: %3$s · 탈퇴: %4$s + 다른 이력과 겹치거나 열린 이력이 둘이 됩니다. + 저장할 수 없습니다. 이력은 겹칠 수 없으며 열린 이력은 하나만 가능합니다. + 이번 주 + 아래로 이동 + 위로 이동 + 가입 중인 멤버가 없습니다 + 가입 이력이 없습니다 + 게임의 서클 › 멤버 화면에서 캡처하거나 홈에서 명단 CSV를 가져오세요. + 서클 데이터를 불러오지 못했습니다. + 현재 + 초기화 + 기본 가입일 순서로 되돌릴까요? 직접 정한 순서는 삭제됩니다. + 모두 선택 + 멤버 선택 + 선택됨 + 정렬 기준 + 목록 전체 정렬 + 이전 버전 + 수동 + 명단 관측 + 정확 (동향) + 가입 + 최근 스냅샷 + 탈퇴 + 숫자 플레이어 UID + + %1$d명 + + + %1$d명 선택 + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 5924f54..8180c91 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -10,4 +10,6 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7cb1d5c..989e1c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,10 +93,10 @@ Search name or UID Active + Withdrawn Active - Sort: name - Sort: Merit This Week - Sort: Total Merit - Sort: last login + Name + Merit This Week + Total Merit + Last login No Platoon Members snapshot has been captured yet. %1$d active · %2$d withdrawn\nLast snapshot: %3$s (%4$s) No matching members @@ -313,7 +313,7 @@ Save settings Enter valid non-negative cutline values. Member order - Sort the list or long-press and drag the handle beside a member. The order is retained for later weekly tables. + Weekly tables list members in this order. Drag a handle to fine-tune it; changes save automatically. Sort by Direction Join date @@ -321,9 +321,9 @@ Total Merit Ascending Descending - Apply sort + Apply to list Reset member order - Long-press to move %1$s + Drag to move %1$s Edit member order Copy CSV GF2logger snapshot comparison CSV @@ -467,4 +467,70 @@ More options %1$s · %2$s · ID %3$d PROFILE + Done + Continue + Server for %1$s + Cancel selection + %1$d joined · %2$d withdrew · %3$d changed + Compared snapshots + Current + Date: %1$s + Time: %1$s + A member with this UID already exists. + Exact time from in-game Updates · read-only + All + Withdrawn + Withdrawal must be after the join date. + Select both the join and withdrawal dates. + Enter a nickname. + Enter a positive numeric UID. + Last login + Login %1$s + Level + Lv. %1$d + Comparing snapshots… + Loading members… + %1$s: merit this week +%2$d, total merit +%3$d, Gunsmoke score +%4$d + Counter changes · %1$d + Shown everywhere in place of 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. + Personal notes about this member + Profile + %1$s → %2$s +Join: %3$s · Withdraw: %4$s + This period overlaps another period or leaves two periods open. + Unable to save. Periods cannot overlap, and only one can stay open. + This week + Move down + Move up + No active members + No membership history + Run a capture in the game\'s Platoon › Members page, or import a roster CSV from Home. + Unable to load Platoon data. + Present + Reset + Return to the default join-date order? Your custom order will be removed. + Select all + Select members + Selected + Sort by + Sort the whole list + legacy import + manual + observed in roster + exact (Updates) + Active + Last snapshot + Withdrawn + Numeric player UID + + %1$d member + %1$d members + + + %1$d selected + %1$d selected + From f39d19e598d6b135378eeb33af34638cd18e34a6 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:15:50 +0900 Subject: [PATCH 017/125] refactor: split PlatoonDatabase into focused SQL objects --- .../app/management/ActivityIngestion.kt | 430 ++ .../management/ExactBoundaryCorrelation.kt | 689 +++ .../app/management/MembershipEditing.kt | 380 ++ .../gf2log/app/management/MembershipRows.kt | 239 + .../gf2log/app/management/PlatoonDatabase.kt | 4066 +---------------- .../management/PlatoonDatabaseMigrations.kt | 462 ++ .../app/management/PlatoonDatabaseSchema.kt | 240 + .../app/management/PlatoonDatabaseSql.kt | 109 + .../app/management/PlatoonReadQueries.kt | 268 ++ .../app/management/RosterHistoryReplay.kt | 446 ++ .../app/management/SnapshotIngestion.kt | 350 ++ .../app/management/WeeklyAnnotationStore.kt | 160 + .../app/management/WeeklyHistoryStore.kt | 184 + 13 files changed, 4035 insertions(+), 3988 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/MembershipRows.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSchema.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt create mode 100644 app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt diff --git a/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt b/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt new file mode 100644 index 0000000..51aff66 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt @@ -0,0 +1,430 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.ExactBoundaryCorrelation.applyExactMembershipBoundary +import dev.gf2log.app.management.PlatoonDatabase.Companion.DAILY_PATROL_REWARD_ACTION_ID +import dev.gf2log.app.management.PlatoonDatabase.Companion.MAX_STORED_ACTIVITY_OBSERVATIONS +import java.time.Instant + +/** Stores 21935 activity facts, resolves names to UIDs, and correlates them with inferred boundaries. */ +internal object ActivityIngestion { + fun ingest( + db: SQLiteDatabase, + observations: List, + capturedAt: Instant, + ): ActivityIngestResult { + val acceptedObservations = PlatoonObservationPolicy.activity(observations) + if (acceptedObservations.isEmpty()) return ActivityIngestResult(0, 0, 0) + return db.inTransaction { + var inserted = 0 + var resolved = 0 + acceptedObservations.forEach { observation -> + val resolvedUid = resolveUidForName( + db, + observation.memberName, + observation.occurredAt, + ) + val result = db.insertWithOnConflict( + "platoon_activity", + null, + ContentValues().apply { + put("occurred_at", observation.occurredAt.toEpochMilli()) + put("action_id", observation.actionId) + put("kind", observation.kind) + put("member_name", observation.memberName) + put("captured_at", capturedAt.toEpochMilli()) + putNullableLong("resolved_uid", resolvedUid) + put( + "resolution", + if (resolvedUid == null) { + ActivityResolution.UNRESOLVED.name + } else { + ActivityResolution.UNIQUE_ROSTER_NAME.name + }, + ) + }, + SQLiteDatabase.CONFLICT_IGNORE, + ) + if (result != -1L) { + inserted += 1 + if (resolvedUid != null) resolved += 1 + } + } + trimPlatoonActivity(db) + resolveUnresolvedActivityUids(db) + reconcileInferredMembershipBoundaries(db) + ActivityIngestResult(acceptedObservations.size, inserted, resolved) + } + } + + private fun resolveUidForName( + db: SQLiteDatabase, + memberName: String, + occurredAt: Instant, + ): Long? { + val windowStart = occurredAt.minusMillis(NAME_RESOLUTION_WINDOW_MILLIS).toEpochMilli() + val windowEnd = occurredAt.plusMillis(NAME_RESOLUTION_WINDOW_MILLIS).toEpochMilli() + val historical = db.rawQuery( + """ + SELECT DISTINCT sm.uid + FROM snapshot_members sm + JOIN snapshots s ON s.id = sm.snapshot_id + WHERE sm.name = ? COLLATE BINARY + AND s.captured_at >= ? + AND s.captured_at <= ? + LIMIT 2 + """.trimIndent(), + arrayOf(memberName, windowStart.toString(), windowEnd.toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getLong(0)) + } + } + if (historical.size == 1) return historical.single() + if (historical.size > 1) return null + + val current = db.rawQuery( + "SELECT uid FROM members WHERE current_name = ? COLLATE BINARY LIMIT 2", + arrayOf(memberName), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getLong(0)) + } + } + return current.singleOrNull() + } + + fun resolveUnresolvedActivityUids(db: SQLiteDatabase) { + PlatoonDatabaseSchema.createPlatoonMaintenanceStateTable(db) + val cursorId = db.rawQuery( + "SELECT value FROM platoon_maintenance_state WHERE key = ?", + arrayOf(ACTIVITY_RESOLUTION_CURSOR_KEY), + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getLong(0) else 0L + } + val unresolved = readUnresolvedActivityBatch( + db = db, + selection = "resolved_uid IS NULL AND id > ?", + selectionArgs = arrayOf(cursorId.toString()), + limit = MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS, + ).toMutableList() + if (unresolved.size < MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS) { + unresolved += readUnresolvedActivityBatch( + db = db, + selection = "resolved_uid IS NULL AND id <= ?", + selectionArgs = arrayOf(cursorId.toString()), + limit = MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS - unresolved.size, + ) + } + unresolved.forEach { activity -> + val uid = resolveUidForName(db, activity.memberName, activity.occurredAt) + ?: return@forEach + val updated = db.updateWithOnConflict( + "platoon_activity", + ContentValues().apply { + put("resolved_uid", uid) + put("resolution", ActivityResolution.UNIQUE_ROSTER_NAME.name) + }, + "id = ? AND resolved_uid IS NULL", + arrayOf(activity.id.toString()), + SQLiteDatabase.CONFLICT_IGNORE, + ) + if (updated == 0) { + db.delete( + "platoon_activity", + """ + id = ? AND EXISTS ( + SELECT 1 + FROM platoon_activity existing + WHERE existing.id != ? + AND existing.occurred_at = ? + AND existing.action_id = ? + AND existing.kind = ? + AND existing.resolved_uid = ? + ) + """.trimIndent(), + arrayOf( + activity.id.toString(), + activity.id.toString(), + activity.occurredAt.toEpochMilli().toString(), + activity.actionId.toString(), + activity.kind.toString(), + uid.toString(), + ), + ) + } + } + unresolved.lastOrNull()?.let { activity -> + db.insertWithOnConflict( + "platoon_maintenance_state", + null, + ContentValues().apply { + put("key", ACTIVITY_RESOLUTION_CURSOR_KEY) + put("value", activity.id) + }, + SQLiteDatabase.CONFLICT_REPLACE, + ) + } + } + + private fun readUnresolvedActivityBatch( + db: SQLiteDatabase, + selection: String, + selectionArgs: Array, + limit: Int, + ): List { + if (limit <= 0) return emptyList() + return db.query( + "platoon_activity", + arrayOf("id", "occurred_at", "member_name", "action_id", "kind"), + selection, + selectionArgs, + null, + null, + "id", + limit.toString(), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + UnresolvedActivity( + id = cursor.getLong(0), + occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), + memberName = cursor.getString(2), + actionId = cursor.getLong(3), + kind = cursor.getLong(4), + ), + ) + } + } + } + } + + /** + * Caps network-derived activity evidence to the newest bounded history. Runs on open and after + * each ingest so an older oversized database self-heals. + */ + fun trimPlatoonActivity(db: SQLiteDatabase) { + db.execSQL( + """ + DELETE FROM platoon_activity + WHERE id IN ( + SELECT id + FROM platoon_activity + ORDER BY captured_at DESC, id DESC + LIMIT -1 OFFSET $MAX_STORED_ACTIVITY_OBSERVATIONS + ) + """.trimIndent(), + ) + } + + fun correlateMembershipBoundary( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + type: MemberEventType, + boundary: MembershipBoundary, + observedAt: Instant, + from: Instant?, + nameIsUnique: Boolean, + ) { + if (!nameIsUnique || from == null) return + val candidates = readMembershipActivityCandidates( + db = db, + memberName = memberName, + from = from, + until = observedAt, + ) + val occurredAt = candidates.map(ActivityCandidate::occurredAt).distinct().singleOrNull() + ?: return + applyExactMembershipBoundary( + db = db, + membershipPeriodId = membershipPeriodId, + uid = uid, + memberName = memberName, + type = type, + boundary = boundary, + occurredAt = occurredAt, + observedAt = observedAt, + activityIds = candidates.map(ActivityCandidate::id), + ) + } + + private fun readMembershipActivityCandidates( + db: SQLiteDatabase, + memberName: String, + from: Instant, + until: Instant, + ): List = db.rawQuery( + """ + SELECT id, occurred_at + FROM platoon_activity + WHERE member_name = ? COLLATE BINARY + AND occurred_at > ? + AND occurred_at <= ? + AND action_id NOT IN (?, ?) + AND member_event_id IS NULL + ORDER BY occurred_at, id + """.trimIndent(), + arrayOf( + memberName, + from.toEpochMilli().toString(), + until.toEpochMilli().toString(), + DAILY_PATROL_REWARD_ACTION_ID.toString(), + DAILY_PATROL_RELATED_ACTION_ID.toString(), + ), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add(ActivityCandidate(cursor.getLong(0), Instant.ofEpochMilli(cursor.getLong(1)))) + } + } + } + + private fun reconcileInferredMembershipBoundaries(db: SQLiteDatabase) { + val activities = db.rawQuery( + """ + SELECT id, occurred_at, member_name, resolved_uid + FROM platoon_activity + WHERE member_event_id IS NULL + AND resolved_uid IS NOT NULL + AND action_id NOT IN (?, ?) + ORDER BY occurred_at, id + """.trimIndent(), + arrayOf( + DAILY_PATROL_REWARD_ACTION_ID.toString(), + DAILY_PATROL_RELATED_ACTION_ID.toString(), + ), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + ResolvedActivityCandidate( + id = cursor.getLong(0), + occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), + memberName = cursor.getString(2), + uid = cursor.getLong(3), + ), + ) + } + } + } + activities.forEach { activity -> + val boundaries = readNearbyInferredBoundaries(db, activity) + val boundary = boundaries.singleOrNull() ?: return@forEach + val eventType = when (boundary.boundary) { + MembershipBoundary.WITHDRAW -> MemberEventType.LEFT + MembershipBoundary.JOIN -> + MembershipRows.joinEventType(db, boundary.uid, boundary.membershipPeriodId) + } + applyExactMembershipBoundary( + db = db, + membershipPeriodId = boundary.membershipPeriodId, + uid = boundary.uid, + memberName = activity.memberName, + type = eventType, + boundary = boundary.boundary, + occurredAt = activity.occurredAt, + observedAt = boundary.inferredAt, + activityIds = listOf(activity.id), + ) + } + } + + private fun readNearbyInferredBoundaries( + db: SQLiteDatabase, + activity: ResolvedActivityCandidate, + ): List = db.rawQuery( + """ + SELECT id, uid, joined_at, left_at, joined_precision, left_precision + FROM membership_periods + WHERE uid = ? + AND ( + (joined_at IS NOT NULL AND joined_precision = ? AND ABS(joined_at - ?) <= ?) + OR + (left_at IS NOT NULL AND left_precision = ? AND ABS(left_at - ?) <= ?) + ) + """.trimIndent(), + arrayOf( + activity.uid.toString(), + EvidencePrecision.INFERRED.name, + activity.occurredAt.toEpochMilli().toString(), + MEMBERSHIP_CORRELATION_WINDOW_MILLIS.toString(), + EvidencePrecision.INFERRED.name, + activity.occurredAt.toEpochMilli().toString(), + MEMBERSHIP_CORRELATION_WINDOW_MILLIS.toString(), + ), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + val membershipPeriodId = cursor.getLong(0) + val uid = cursor.getLong(1) + val joinedAt = cursor.getNullableLong(2) + val leftAt = cursor.getNullableLong(3) + if (joinedAt != null && + cursor.getString(4) == EvidencePrecision.INFERRED.name && + kotlin.math.abs(joinedAt - activity.occurredAt.toEpochMilli()) <= + MEMBERSHIP_CORRELATION_WINDOW_MILLIS + ) { + add( + InferredBoundary( + membershipPeriodId, + uid, + MembershipBoundary.JOIN, + Instant.ofEpochMilli(joinedAt), + ), + ) + } + if (leftAt != null && + cursor.getNullableString(5) == EvidencePrecision.INFERRED.name && + kotlin.math.abs(leftAt - activity.occurredAt.toEpochMilli()) <= + MEMBERSHIP_CORRELATION_WINDOW_MILLIS + ) { + add( + InferredBoundary( + membershipPeriodId, + uid, + MembershipBoundary.WITHDRAW, + Instant.ofEpochMilli(leftAt), + ), + ) + } + } + } + } + + private data class ActivityCandidate( + val id: Long, + val occurredAt: Instant, + ) + + private data class ResolvedActivityCandidate( + val id: Long, + val occurredAt: Instant, + val memberName: String, + val uid: Long, + ) + + private data class UnresolvedActivity( + val id: Long, + val occurredAt: Instant, + val memberName: String, + val actionId: Long, + val kind: Long, + ) + + private data class InferredBoundary( + val membershipPeriodId: Long, + val uid: Long, + val boundary: MembershipBoundary, + val inferredAt: Instant, + ) + + private const val MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS = 250 + private const val ACTIVITY_RESOLUTION_CURSOR_KEY = "activity_resolution_cursor" + private const val DAILY_PATROL_RELATED_ACTION_ID = 801005L + private const val NAME_RESOLUTION_WINDOW_MILLIS = 30L * 24L * 60L * 60L * 1000L + private const val MEMBERSHIP_CORRELATION_WINDOW_MILLIS = 12L * 60L * 60L * 1000L +} diff --git a/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt b/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt new file mode 100644 index 0000000..7ca7dba --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt @@ -0,0 +1,689 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.MembershipRows.ensureRosterActiveMembershipPeriod +import dev.gf2log.app.management.MembershipRows.insertEvent +import dev.gf2log.app.management.MembershipRows.insertMembershipPeriod +import dev.gf2log.app.management.MembershipRows.joinEventType +import dev.gf2log.app.management.MembershipRows.memberExists +import dev.gf2log.app.management.PlatoonDatabase.Companion.DAILY_PATROL_REWARD_ACTION_ID +import java.time.Instant + +/** Applies UID-safe 21960 Updates boundaries and folds weaker roster or manual evidence into them. */ +internal object ExactBoundaryCorrelation { + fun ingest( + db: SQLiteDatabase, + observations: List, + capturedAt: Instant, + ): UpdatesIngestResult { + val acceptedObservations = PlatoonObservationPolicy.updates(observations) + if (acceptedObservations.isEmpty()) return UpdatesIngestResult(0, 0, 0) + return db.inTransaction { + var membershipEvents = 0 + var patrolFacts = 0 + acceptedObservations.forEach { observation -> + observation.members.forEach { + ensureUpdateMember(db, it, observation.occurredAt) + } + val affectedMembers = PlatoonUpdateSemantics.affectedMembers( + kind = observation.kind, + members = observation.members, + ) + val effect = PlatoonUpdateSemantics.effect(observation.kind) + val exactBoundary = EXACT_BOUNDARIES[effect] + if (exactBoundary != null) { + val (type, boundary) = exactBoundary + affectedMembers.forEach { member -> + if (applyExactUpdateBoundary( + db = db, + member = member, + type = type, + boundary = boundary, + occurredAt = observation.occurredAt, + observedAt = capturedAt, + ) + ) { + membershipEvents += 1 + } + synchronizeCurrentMembershipFromUpdate(db, member.uid) + } + } else if (effect == PlatoonUpdateEffect.DAILY_PATROL) { + affectedMembers.forEach { member -> + val inserted = db.insertWithOnConflict( + "platoon_activity", + null, + ContentValues().apply { + put("occurred_at", observation.occurredAt.toEpochMilli()) + put("action_id", DAILY_PATROL_REWARD_ACTION_ID) + put("kind", observation.kind) + put("member_name", member.name) + put("captured_at", capturedAt.toEpochMilli()) + put("resolved_uid", member.uid) + put("resolution", ActivityResolution.EXACT_UPDATE.name) + }, + SQLiteDatabase.CONFLICT_IGNORE, + ) + if (inserted != -1L) patrolFacts += 1 + } + } + } + ActivityIngestion.trimPlatoonActivity(db) + UpdatesIngestResult(acceptedObservations.size, membershipEvents, patrolFacts) + } + } + + private fun synchronizeCurrentMembershipFromUpdate( + db: SQLiteDatabase, + uid: Long, + ) { + val latestSnapshotAt = SnapshotIngestion.latestSnapshotCapturedAt(db) + val snapshotFilter = if (latestSnapshotAt == null) "" else "AND occurred_at > ?" + val arguments = mutableListOf( + uid.toString(), + EvidenceSource.GAME_UPDATES.name, + EvidencePrecision.EXACT.name, + MemberEventType.JOINED.name, + MemberEventType.REJOINED.name, + MemberEventType.LEFT.name, + MemberEventType.REMOVED.name, + ).apply { + latestSnapshotAt?.let { add(it.toEpochMilli().toString()) } + } + val latestBoundary = db.rawQuery( + """ + SELECT event_type, note, occurred_at + FROM member_events + WHERE uid = ? + AND source = ? + AND precision = ? + AND event_type IN (?, ?, ?, ?) + AND occurred_at IS NOT NULL + $snapshotFilter + ORDER BY occurred_at DESC, + CASE event_type + WHEN '${MemberEventType.REMOVED.name}' THEN 0 + WHEN '${MemberEventType.LEFT.name}' THEN 1 + WHEN '${MemberEventType.REJOINED.name}' THEN 2 + ELSE 3 + END, + id DESC + LIMIT 1 + """.trimIndent(), + arguments.toTypedArray(), + ).use { cursor -> + if (!cursor.moveToFirst()) return + LatestMembershipBoundary( + type = MemberEventType.valueOf(cursor.getString(0)), + name = cursor.getString(1), + occurredAt = Instant.ofEpochMilli(cursor.getLong(2)), + ) + } + db.update( + "members", + ContentValues().apply { + put("current_name", latestBoundary.name) + put( + "is_active", + if (latestBoundary.type in setOf( + MemberEventType.JOINED, + MemberEventType.REJOINED, + ) + ) { + 1 + } else { + 0 + }, + ) + put("last_seen_at", latestBoundary.occurredAt.toEpochMilli()) + }, + "uid = ?", + arrayOf(uid.toString()), + ) + } + + private data class LatestMembershipBoundary( + val type: MemberEventType, + val name: String, + val occurredAt: Instant, + ) + + private fun ensureUpdateMember( + db: SQLiteDatabase, + member: PlatoonUpdateMemberObservation, + observedAt: Instant, + ) { + if (memberExists(db, member.uid)) return + MembershipRows.insertMember( + db = db, + uid = member.uid, + name = member.name, + level = 0, + isActive = false, + firstSeenAt = observedAt, + lastSeenAt = observedAt, + ) + } + + fun findRosterConfirmedExactMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + boundary: MembershipBoundary, + from: Instant?, + observedAt: Instant, + ): Long? { + val boundaryColumn = boundary.atColumn + val sourceColumn = boundary.sourceColumn + val precisionColumn = boundary.precisionColumn + val compatibility = if (boundary == MembershipBoundary.JOIN) { + "(left_at IS NULL OR left_at >= ?)" + } else { + "(joined_at IS NULL OR joined_at <= ?)" + } + val fromClause = if (from == null) "" else "AND $boundaryColumn > ?" + val arguments = buildList { + add(uid.toString()) + add(EvidenceSource.GAME_UPDATES.name) + add(EvidencePrecision.EXACT.name) + add(observedAt.toEpochMilli().toString()) + if (from != null) add(from.toEpochMilli().toString()) + add(observedAt.toEpochMilli().toString()) + }.toTypedArray() + val candidates = db.rawQuery( + """ + SELECT id, $boundaryColumn + FROM membership_periods + WHERE uid = ? + AND $sourceColumn = ? + AND $precisionColumn = ? + AND $boundaryColumn <= ? + $fromClause + AND $compatibility + ORDER BY $boundaryColumn DESC, id DESC + """.trimIndent(), + arguments, + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add(cursor.getLong(0) to Instant.ofEpochMilli(cursor.getLong(1))) + } + } + } + return candidates.firstOrNull { (_, boundaryAt) -> + !hasLaterOppositeBoundaryThrough( + db = db, + uid = uid, + boundary = boundary, + boundaryAt = boundaryAt, + observedAt = observedAt, + ) + }?.first + } + + private fun applyExactUpdateBoundary( + db: SQLiteDatabase, + member: PlatoonUpdateMemberObservation, + type: MemberEventType, + boundary: MembershipBoundary, + occurredAt: Instant, + observedAt: Instant, + ): Boolean { + val boundaryTypes = boundary.eventTypes + val exactExists = db.rawQuery( + """ + SELECT 1 + FROM member_events + WHERE uid = ? + AND source = ? + AND occurred_at = ? + AND event_type IN (?, ?) + LIMIT 1 + """.trimIndent(), + arrayOf( + member.uid.toString(), + EvidenceSource.GAME_UPDATES.name, + occurredAt.toEpochMilli().toString(), + boundaryTypes[0].name, + boundaryTypes[1].name, + ), + ).use(Cursor::moveToFirst) + if (exactExists) return false + + val candidates = db.rawQuery( + """ + SELECT id, membership_period_id, COALESCE(occurred_at, observed_at), + ABS(COALESCE(occurred_at, observed_at) - ?) AS distance + FROM member_events + WHERE uid = ? + AND event_type IN (?, ?) + AND source != ? + AND ABS(COALESCE(occurred_at, observed_at) - ?) <= ? + ORDER BY distance, + CASE source + WHEN '${EvidenceSource.GAME_UPDATES.name}' THEN 0 + WHEN '${EvidenceSource.MANUAL.name}' THEN 1 + ELSE 2 + END, + id DESC + """.trimIndent(), + arrayOf( + occurredAt.toEpochMilli().toString(), + member.uid.toString(), + boundaryTypes[0].name, + boundaryTypes[1].name, + EvidenceSource.GAME_UPDATES.name, + occurredAt.toEpochMilli().toString(), + EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), + ), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + BoundaryEventCandidate( + id = cursor.getLong(0), + membershipPeriodId = cursor.getNullableLong(1), + boundaryAt = Instant.ofEpochMilli(cursor.getLong(2)), + ), + ) + } + } + } + val correlatableCandidates = candidates.filter { candidate -> + !hasOppositeBoundaryBetween( + db = db, + uid = member.uid, + boundary = boundary, + first = candidate.boundaryAt, + second = occurredAt, + ) + } + val membershipPeriodId = correlatableCandidates + .firstNotNullOfOrNull(BoundaryEventCandidate::membershipPeriodId) + ?: findOrCreateUpdateMembershipPeriod(db, member.uid, boundary, occurredAt) + + correlatableCandidates.forEach { candidate -> + db.delete("member_events", "id = ?", arrayOf(candidate.id.toString())) + } + correlatableCandidates.mapNotNull(BoundaryEventCandidate::membershipPeriodId) + .filter { it != membershipPeriodId } + .distinct() + .forEach { candidateMembershipPeriodId -> + mergeShadowInferredMembershipPeriod( + db = db, + selectedMembershipPeriodId = membershipPeriodId, + shadowMembershipPeriodId = candidateMembershipPeriodId, + boundary = boundary, + occurredAt = occurredAt, + ) + } + + applyExactMembershipBoundary( + db = db, + membershipPeriodId = membershipPeriodId, + uid = member.uid, + memberName = member.name, + type = if (boundary == MembershipBoundary.JOIN) { + joinEventType(db, member.uid, membershipPeriodId) + } else { + type + }, + boundary = boundary, + occurredAt = occurredAt, + observedAt = observedAt, + activityIds = emptyList(), + ) + if (boundary == MembershipBoundary.WITHDRAW && + MembershipChronology.shouldRestoreRosterActiveMembershipPeriod( + memberIsActive = isActiveMember(db, member.uid), + withdrawalPredatesRoster = hasRosterPresenceAfter(db, member.uid, occurredAt), + ) + ) { + /* + * The historical boundary and a later authoritative roster are + * both true. Close the compatible historical membership period above, then + * preserve exactly one unknown current period for the later roster. + * A subsequent exact rejoin in the replay will refine this period. + */ + ensureRosterActiveMembershipPeriod(db, member.uid, EvidenceSource.SNAPSHOT) + } + return true + } + + private fun hasOppositeBoundaryBetween( + db: SQLiteDatabase, + uid: Long, + boundary: MembershipBoundary, + first: Instant, + second: Instant, + ): Boolean { + if (first == second) return false + val oppositeTypes = boundary.opposite.eventTypes + val start = minOf(first, second).toEpochMilli() + val end = maxOf(first, second).toEpochMilli() + return db.rawQuery( + """ + SELECT 1 + FROM member_events + WHERE uid = ? + AND event_type IN (?, ?) + AND COALESCE(occurred_at, observed_at) > ? + AND COALESCE(occurred_at, observed_at) < ? + LIMIT 1 + """.trimIndent(), + arrayOf( + uid.toString(), + oppositeTypes[0].name, + oppositeTypes[1].name, + start.toString(), + end.toString(), + ), + ).use(Cursor::moveToFirst) + } + + private fun hasLaterOppositeBoundaryThrough( + db: SQLiteDatabase, + uid: Long, + boundary: MembershipBoundary, + boundaryAt: Instant, + observedAt: Instant, + ): Boolean { + if (!observedAt.isAfter(boundaryAt)) return false + val oppositeTypes = boundary.opposite.eventTypes + val oppositeBoundaryAt = db.rawQuery( + """ + SELECT MIN(COALESCE(occurred_at, observed_at)) + FROM member_events + WHERE uid = ? + AND event_type IN (?, ?) + AND COALESCE(occurred_at, observed_at) > ? + AND COALESCE(occurred_at, observed_at) <= ? + """.trimIndent(), + arrayOf( + uid.toString(), + oppositeTypes[0].name, + oppositeTypes[1].name, + boundaryAt.toEpochMilli().toString(), + observedAt.toEpochMilli().toString(), + ), + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getNullableInstant(0) else null + } + return MembershipChronology.hasSupersedingOppositeBoundary( + boundaryAt = boundaryAt, + observedAt = observedAt, + oppositeBoundaryTimes = listOfNotNull(oppositeBoundaryAt), + ) + } + + private fun findOrCreateUpdateMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + boundary: MembershipBoundary, + occurredAt: Instant, + ): Long { + val occurredAtMillis = occurredAt.toEpochMilli() + val nearby = when (boundary) { + MembershipBoundary.JOIN -> db.rawQuery( + """ + SELECT id + FROM membership_periods + WHERE uid = ? + AND (left_at IS NULL OR left_at >= ?) + AND ( + joined_at IS NULL + OR ( + joined_precision != ? + AND ABS(joined_at - ?) <= ? + ) + ) + ORDER BY + CASE WHEN left_at IS NULL THEN 0 ELSE 1 END, + CASE WHEN joined_at IS NULL THEN 0 ELSE 1 END, + ABS(COALESCE(joined_at, ?) - ?), + id DESC + LIMIT 1 + """.trimIndent(), + arrayOf( + uid.toString(), + occurredAtMillis.toString(), + EvidencePrecision.EXACT.name, + occurredAtMillis.toString(), + EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), + occurredAtMillis.toString(), + occurredAtMillis.toString(), + ), + ) + MembershipBoundary.WITHDRAW -> db.rawQuery( + """ + SELECT id + FROM membership_periods + WHERE uid = ? + AND (joined_at IS NULL OR joined_at <= ?) + AND ( + left_at IS NULL + OR ( + left_precision != ? + AND ABS(left_at - ?) <= ? + ) + ) + ORDER BY + CASE WHEN left_at IS NULL THEN 0 ELSE 1 END, + CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, + joined_at DESC, + ABS(COALESCE(left_at, ?) - ?), + id DESC + LIMIT 1 + """.trimIndent(), + arrayOf( + uid.toString(), + occurredAtMillis.toString(), + EvidencePrecision.EXACT.name, + occurredAtMillis.toString(), + EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), + occurredAtMillis.toString(), + occurredAtMillis.toString(), + ), + ) + }.use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + if (nearby != null) return nearby + return insertMembershipPeriod( + db = db, + uid = uid, + joinedAt = if (boundary == MembershipBoundary.JOIN) occurredAt else null, + precision = if (boundary == MembershipBoundary.JOIN) { + EvidencePrecision.EXACT + } else { + EvidencePrecision.UNKNOWN + }, + source = EvidenceSource.GAME_UPDATES, + ) + } + + private fun isActiveMember(db: SQLiteDatabase, uid: Long): Boolean = + db.rawQuery( + "SELECT is_active FROM members WHERE uid = ? LIMIT 1", + arrayOf(uid.toString()), + ).use { cursor -> cursor.moveToFirst() && cursor.getInt(0) != 0 } + + private fun hasRosterPresenceAfter( + db: SQLiteDatabase, + uid: Long, + occurredAt: Instant, + ): Boolean { + val latestRosterPresenceAt = db.rawQuery( + """ + SELECT MAX(snapshot.captured_at) + FROM snapshots snapshot + JOIN snapshot_members member ON member.snapshot_id = snapshot.id + WHERE member.uid = ? + """.trimIndent(), + arrayOf(uid.toString()), + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getNullableInstant(0) else null + } + return MembershipChronology.withdrawalPredatesRosterPresence( + withdrewAt = occurredAt, + latestRosterPresenceAt = latestRosterPresenceAt, + ) + } + + /** + * Folds a weaker inferred period that recorded the same boundary into the selected period. + * Its opposite boundary moves over only when the selected period has none of its own. + */ + private fun mergeShadowInferredMembershipPeriod( + db: SQLiteDatabase, + selectedMembershipPeriodId: Long, + shadowMembershipPeriodId: Long, + boundary: MembershipBoundary, + occurredAt: Instant, + ) { + val opposite = boundary.opposite.prefix + val shadowOpposite = db.rawQuery( + """ + SELECT ${opposite}_at, ${opposite}_date, ${opposite}_time_known, + ${opposite}_precision, ${opposite}_source + FROM membership_periods + WHERE id = ? + AND ${boundary.precisionColumn} IN (?, ?) + AND ( + ${boundary.atColumn} IS NULL + OR ABS(${boundary.atColumn} - ?) <= ? + ) + LIMIT 1 + """.trimIndent(), + arrayOf( + shadowMembershipPeriodId.toString(), + EvidencePrecision.INFERRED.name, + EvidencePrecision.UNKNOWN.name, + occurredAt.toEpochMilli().toString(), + EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), + ), + ).use { cursor -> + if (!cursor.moveToFirst()) { + null + } else { + ShadowBoundary( + at = cursor.getNullableLong(0), + date = cursor.getNullableLong(1), + timeKnown = cursor.getNullableInt(2), + precision = cursor.getNullableString(3), + source = cursor.getNullableString(4), + ) + } + } ?: return + + if (shadowOpposite.at != null) { + val selectedHasOpposite = db.rawQuery( + "SELECT ${opposite}_at FROM membership_periods WHERE id = ?", + arrayOf(selectedMembershipPeriodId.toString()), + ).use { cursor -> cursor.moveToFirst() && !cursor.isNull(0) } + if (selectedHasOpposite) return + + db.update( + "membership_periods", + ContentValues().apply { + put("${opposite}_at", shadowOpposite.at) + putNullableLong("${opposite}_date", shadowOpposite.date) + putNullableInt("${opposite}_time_known", shadowOpposite.timeKnown) + put("${opposite}_precision", shadowOpposite.precision) + put("${opposite}_source", shadowOpposite.source) + }, + "id = ?", + arrayOf(selectedMembershipPeriodId.toString()), + ) + val oppositeTypes = boundary.opposite.eventTypes + db.update( + "member_events", + ContentValues().apply { put("membership_period_id", selectedMembershipPeriodId) }, + "membership_period_id = ? AND event_type IN (?, ?)", + arrayOf( + shadowMembershipPeriodId.toString(), + oppositeTypes[0].name, + oppositeTypes[1].name, + ), + ) + } + db.delete("member_events", "membership_period_id = ?", arrayOf(shadowMembershipPeriodId.toString())) + db.delete("membership_periods", "id = ?", arrayOf(shadowMembershipPeriodId.toString())) + } + + fun applyExactMembershipBoundary( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + type: MemberEventType, + boundary: MembershipBoundary, + occurredAt: Instant, + observedAt: Instant, + activityIds: List, + ) { + 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) + } + 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 (?, ?)", + arrayOf( + membershipPeriodId.toString(), + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + inferredTypes[0], + inferredTypes[1], + ), + ) + val eventId = insertEvent( + db = db, + uid = uid, + type = type, + occurredAt = occurredAt, + observedAt = observedAt, + precision = EvidencePrecision.EXACT, + source = EvidenceSource.GAME_UPDATES, + note = memberName, + membershipPeriodId = membershipPeriodId, + ) + activityIds.forEach { activityId -> + db.update( + "platoon_activity", + ContentValues().apply { + put("resolved_uid", uid) + put("resolution", ActivityResolution.MEMBERSHIP_CORRELATION.name) + put("member_event_id", eventId) + }, + "id = ? AND member_event_id IS NULL", + arrayOf(activityId.toString()), + ) + } + } + + private data class BoundaryEventCandidate( + val id: Long, + val membershipPeriodId: Long?, + val boundaryAt: Instant, + ) + + private data class ShadowBoundary( + val at: Long?, + val date: Long?, + val timeKnown: Int?, + val precision: String?, + val source: String?, + ) + + private val EXACT_BOUNDARIES = mapOf( + PlatoonUpdateEffect.JOIN to (MemberEventType.JOINED to MembershipBoundary.JOIN), + PlatoonUpdateEffect.WITHDRAW to (MemberEventType.LEFT to MembershipBoundary.WITHDRAW), + PlatoonUpdateEffect.REMOVED to (MemberEventType.REMOVED to MembershipBoundary.WITHDRAW), + ) + + private const val EXACT_UPDATE_CORRELATION_WINDOW_MILLIS = 48L * 60L * 60L * 1000L +} diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt new file mode 100644 index 0000000..2612903 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -0,0 +1,380 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.MembershipRows.insertEvent +import dev.gf2log.app.management.MembershipRows.joinEventType +import dev.gf2log.app.management.MembershipRows.memberExists +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** Manual member and membership-period edits, each verified against the canonical timeline. */ +internal object MembershipEditing { + fun addWithdrawnMember( + db: SQLiteDatabase, + uid: Long, + name: String, + joined: MembershipBoundaryValue, + withdrew: MembershipBoundaryValue, + note: String, + ): Boolean { + require(uid > 0) + require(name.isNotBlank()) + require(isValidMembershipRange(joined, withdrew)) + return db.inTransaction { + if (memberExists(db, uid)) return false + MembershipRows.insertMember( + db = db, + uid = uid, + name = name.trim(), + level = 0, + isActive = false, + firstSeenAt = joined.instant, + lastSeenAt = withdrew.instant, + customName = name.trim(), + ) + val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) + replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, name.trim(), joined, withdrew) + synchronizeAndVerifyMembershipState(db, uid) + true + } + } + + fun addMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + joined: MembershipBoundaryValue, + withdrew: MembershipBoundaryValue?, + note: String, + ): Boolean { + require(isValidMembershipRange(joined, withdrew)) + return db.inTransaction { + val memberName = memberName(db, uid) ?: return false + if (!canStoreMembershipPeriod(db, uid, null, joined, withdrew)) return false + val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) + replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, memberName, joined, withdrew) + synchronizeAndVerifyMembershipState(db, uid) + true + } + } + + fun updateMember(db: SQLiteDatabase, uid: Long, name: String, note: String): Boolean { + require(name.isNotBlank()) + val values = ContentValues().apply { + put("custom_name", name.trim()) + put("note", note.trim()) + } + return db.update("members", values, "uid = ?", arrayOf(uid.toString())) == 1 + } + + fun deleteMember(db: SQLiteDatabase, uid: Long): Boolean { + require(uid > 0) + return db.inTransaction { + if (!memberExists(db, uid)) return false + val uidArgument = arrayOf(uid.toString()) + db.delete("weekly_overrides", "uid = ?", uidArgument) + db.delete( + "platoon_activity", + "resolved_uid = ? OR member_event_id IN " + + "(SELECT id FROM member_events WHERE uid = ?)", + arrayOf(uid.toString(), uid.toString()), + ) + db.delete( + "weekly_notes", + "is_automatic = 1 AND event_id IN " + + "(SELECT id FROM member_events WHERE uid = ?)", + uidArgument, + ) + db.delete("member_events", "uid = ?", uidArgument) + db.delete("membership_periods", "uid = ?", uidArgument) + db.delete("snapshot_members", "uid = ?", uidArgument) + val deleted = db.delete("members", "uid = ?", uidArgument) == 1 + check(deleted) { "Member disappeared during deletion" } + true + } + } + + /** + * 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. + */ + fun deleteMembershipPeriod(db: SQLiteDatabase, membershipPeriodId: Long): Boolean { + require(membershipPeriodId > 0) + return db.inTransaction { + val uid = db.rawQuery( + "SELECT uid FROM membership_periods WHERE id = ?", + arrayOf(membershipPeriodId.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + ?: return false + val periodCount = db.rawQuery( + "SELECT COUNT(*) FROM membership_periods WHERE uid = ?", + arrayOf(uid.toString()), + ).use { cursor -> + check(cursor.moveToFirst()) + cursor.getLong(0) + } + if (periodCount <= 1L) return false + if (!MembershipRows.deleteMembershipPeriod(db, membershipPeriodId)) return false + synchronizeAndVerifyMembershipState(db, uid) + true + } + } + + fun updateMembershipPeriod( + db: SQLiteDatabase, + gameZone: () -> ZoneId, + membershipPeriodId: Long, + joined: MembershipBoundaryValue, + left: MembershipBoundaryValue?, + note: String, + ): Boolean = db.inTransaction { + val period = readMembershipPeriodForUpdate(db, membershipPeriodId, gameZone) + ?: return false + // Exact packet-linked boundaries stay immutable; only editable sides take the request. + val effectiveJoined = if (period.joinedSource.isImmutableMembershipBoundary()) { + period.joined + } else { + joined + } ?: return false + val effectiveLeft = if (period.leftSource?.isImmutableMembershipBoundary() == true) { + period.left + } else { + left + } + require(isValidMembershipRange(effectiveJoined, effectiveLeft)) + if (!canStoreMembershipPeriod(db, period.uid, membershipPeriodId, effectiveJoined, effectiveLeft)) { + return false + } + val joinedChanged = editableMembershipBoundaryChanged( + original = period.joined, + requested = joined, + source = period.joinedSource, + ) + val leftChanged = editableMembershipBoundaryChanged( + original = period.left, + requested = left, + source = period.leftSource, + ) + val updated = db.update( + "membership_periods", + ContentValues().apply { + if (joinedChanged) putManualBoundary("joined", joined) + if (leftChanged) putManualBoundary("left", left) + put("note", note.trim()) + }, + "id = ?", + arrayOf(membershipPeriodId.toString()), + ) == 1 + if (updated && (joinedChanged || leftChanged)) { + val joinedIsManual = joinedChanged || period.joinedSource == EvidenceSource.MANUAL + val leftIsManual = if (leftChanged) { + left != null + } else { + period.leftSource == EvidenceSource.MANUAL + } + replaceManualMembershipPeriodEvents( + db, + membershipPeriodId, + period.uid, + memberName(db, period.uid).orEmpty(), + effectiveJoined.takeIf { joinedIsManual }, + effectiveLeft.takeIf { leftIsManual }, + ) + } + if (updated) synchronizeAndVerifyMembershipState(db, period.uid) + updated + } + + /** + * Evaluates a proposed manual period together with every persisted period for the member, + * rejecting overlaps and multiple open periods before any row changes. + */ + private fun canStoreMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + replacedPeriodId: Long?, + joined: MembershipBoundaryValue, + left: MembershipBoundaryValue?, + ): Boolean { + val proposed = MembershipInterval( + id = replacedPeriodId ?: Long.MAX_VALUE, + joinedAt = joined.instant, + leftAt = left?.instant, + joinedDate = joined.date, + leftDate = left?.date, + joinedTimeKnown = joined.timeKnown, + leftTimeKnown = left?.timeKnown ?: true, + ) + val periods = readMembershipIntervals(db, uid) + .filterNot { it.id == replacedPeriodId } + proposed + return MembershipConsistencyPolicy.violation(periods) == null + } + + /** + * Derives current activity from the canonical period timeline, then verifies chronology and + * event ownership before the surrounding transaction may commit. + */ + private fun synchronizeAndVerifyMembershipState(db: SQLiteDatabase, uid: Long) { + val periods = readMembershipIntervals(db, uid) + checkNotNull(periods.takeIf { it.isNotEmpty() }) { "A member must retain a membership period" } + val violation = MembershipConsistencyPolicy.violation(periods) + check(violation == null) { violation.orEmpty() } + val updated = db.update( + "members", + ContentValues().apply { + put("is_active", if (MembershipConsistencyPolicy.isActive(periods)) 1 else 0) + }, + "uid = ?", + arrayOf(uid.toString()), + ) + check(updated == 1) { "Membership state has no owning member" } + val mismatchedEvent = db.rawQuery( + "SELECT 1 FROM member_events event " + + "JOIN membership_periods period ON period.id = event.membership_period_id " + + "WHERE event.uid != period.uid AND (event.uid = ? OR period.uid = ?) LIMIT 1", + arrayOf(uid.toString(), uid.toString()), + ).use(Cursor::moveToFirst) + check(!mismatchedEvent) { "Membership event belongs to a different member" } + } + + private fun readMembershipIntervals(db: SQLiteDatabase, uid: Long): List = + db.rawQuery( + "SELECT id, joined_at, left_at, joined_date, left_date, " + + "joined_time_known, left_time_known " + + "FROM membership_periods WHERE uid = ? ORDER BY id", + arrayOf(uid.toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + MembershipInterval( + id = cursor.getLong(0), + joinedAt = cursor.getNullableInstant(1), + leftAt = cursor.getNullableInstant(2), + joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), + leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), + joinedTimeKnown = cursor.getNullableInt(5)?.let { it != 0 } ?: true, + leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 } ?: true, + ), + ) + } + } + } + + private fun insertManualMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + joined: MembershipBoundaryValue, + withdrew: MembershipBoundaryValue?, + note: String, + ): Long = db.insertOrThrow( + "membership_periods", + null, + ContentValues().apply { + put("uid", uid) + putManualBoundary("joined", joined) + putManualBoundary("left", withdrew) + put("note", note.trim()) + }, + ) + + /** A manual boundary, or a cleared one when [value] is null. */ + private fun ContentValues.putManualBoundary(prefix: String, value: MembershipBoundaryValue?) { + if (value == null) { + clearMembershipBoundary(prefix) + putNull("${prefix}_precision") + putNull("${prefix}_source") + } else { + putMembershipBoundary(prefix, value) + put("${prefix}_precision", EvidencePrecision.MANUAL.name) + put("${prefix}_source", EvidenceSource.MANUAL.name) + } + } + + private fun replaceManualMembershipPeriodEvents( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + joined: MembershipBoundaryValue?, + withdrew: MembershipBoundaryValue?, + ) { + db.delete( + "member_events", + "membership_period_id = ? AND source = ?", + arrayOf(membershipPeriodId.toString(), EvidenceSource.MANUAL.name), + ) + val observedAt = Instant.now() + listOfNotNull( + joined?.let { joinEventType(db, uid, membershipPeriodId) to it }, + withdrew?.let { MemberEventType.LEFT to it }, + ).forEach { (type, boundary) -> + insertEvent( + db = db, + uid = uid, + type = type, + occurredAt = boundary.instant, + eventDate = boundary.date, + timeKnown = boundary.timeKnown, + observedAt = observedAt, + precision = EvidencePrecision.MANUAL, + source = EvidenceSource.MANUAL, + note = memberName, + membershipPeriodId = membershipPeriodId, + ) + } + } + + private fun memberName(db: SQLiteDatabase, uid: Long): String? = + db.rawQuery( + "SELECT COALESCE(custom_name, current_name) FROM members WHERE uid = ?", + arrayOf(uid.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + + private fun readMembershipPeriodForUpdate( + db: SQLiteDatabase, + membershipPeriodId: Long, + gameZone: () -> ZoneId, + ): MembershipPeriodForUpdate? = db.rawQuery( + """ + SELECT uid, joined_at, left_at, joined_date, left_date, + joined_time_known, left_time_known, joined_source, left_source + FROM membership_periods + WHERE id = ? + """.trimIndent(), + arrayOf(membershipPeriodId.toString()), + ).use { cursor -> + if (!cursor.moveToFirst()) { + null + } else { + val zoneId = gameZone() + MembershipPeriodForUpdate( + uid = cursor.getLong(0), + joined = cursor.membershipBoundaryValue( + instantIndex = 1, + dateIndex = 3, + timeKnownIndex = 5, + zoneId = zoneId, + ), + left = cursor.membershipBoundaryValue( + instantIndex = 2, + dateIndex = 4, + timeKnownIndex = 6, + zoneId = zoneId, + ), + joinedSource = EvidenceSource.valueOf(cursor.getString(7)), + leftSource = cursor.getNullableString(8)?.let(EvidenceSource::valueOf), + ) + } + } + + private data class MembershipPeriodForUpdate( + val uid: Long, + val joined: MembershipBoundaryValue?, + val left: MembershipBoundaryValue?, + val joinedSource: EvidenceSource, + val leftSource: EvidenceSource?, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt b/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt new file mode 100644 index 0000000..c25ef82 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt @@ -0,0 +1,239 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import java.time.Instant +import java.time.LocalDate + +/** Row-level member, period, and event writes shared by every evidence path. */ +internal object MembershipRows { + fun memberExists(db: SQLiteDatabase, uid: Long): Boolean = + db.exists("SELECT 1 FROM members WHERE uid = ? LIMIT 1", uid) + + fun insertMember( + db: SQLiteDatabase, + uid: Long, + name: String, + level: Long, + isActive: Boolean, + firstSeenAt: Instant, + lastSeenAt: Instant, + customName: String? = null, + ) { + db.insertOrThrow( + "members", + null, + ContentValues().apply { + put("uid", uid) + put("current_name", name) + if (customName != null) put("custom_name", customName) + put("current_level", level) + put("is_active", if (isActive) 1 else 0) + put("first_seen_at", firstSeenAt.toEpochMilli()) + put("last_seen_at", lastSeenAt.toEpochMilli()) + put("note", "") + }, + ) + } + + fun insertMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + joinedAt: Instant?, + precision: EvidencePrecision, + source: EvidenceSource, + ): Long = db.insertOrThrow( + "membership_periods", + null, + ContentValues().apply { + put("uid", uid) + putNullableLong("joined_at", joinedAt?.toEpochMilli()) + put("joined_precision", precision.name) + put("joined_source", source.name) + }, + ) + + fun closeLatestMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + leftAt: Instant, + precision: EvidencePrecision, + source: EvidenceSource, + ): Long { + 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") + db.update( + "membership_periods", + ContentValues().apply { + put("left_at", leftAt.toEpochMilli()) + putNull("left_date") + put("left_time_known", 1) + put("left_precision", precision.name) + put("left_source", source.name) + }, + "id = ?", + arrayOf(membershipPeriodId.toString()), + ) + return membershipPeriodId + } + + fun ensureRosterActiveMembershipPeriod( + db: SQLiteDatabase, + uid: Long, + source: EvidenceSource, + ) { + if (db.exists("SELECT 1 FROM membership_periods WHERE uid = ? AND left_at IS NULL LIMIT 1", uid)) { + return + } + insertMembershipPeriod( + db = db, + uid = uid, + joinedAt = null, + precision = EvidencePrecision.UNKNOWN, + source = source, + ) + } + + fun hasEarlierMembershipPeriod(db: SQLiteDatabase, uid: Long, membershipPeriodId: Long): Boolean = + db.exists( + """ + SELECT 1 + FROM membership_periods current + JOIN membership_periods other ON other.uid = current.uid AND other.id != current.id + WHERE current.id = ? + AND current.uid = ? + AND ( + (current.joined_at IS NOT NULL AND other.joined_at IS NOT NULL AND + (other.joined_at < current.joined_at OR + (other.joined_at = current.joined_at AND other.id < current.id))) + OR + (current.joined_at IS NULL AND other.id < current.id) + ) + LIMIT 1 + """.trimIndent(), + membershipPeriodId, + uid, + ) + + /** JOINED for a member's first period by join order, otherwise REJOINED. */ + fun joinEventType(db: SQLiteDatabase, uid: Long, membershipPeriodId: Long): MemberEventType = + if (hasEarlierMembershipPeriod(db, uid, membershipPeriodId)) { + MemberEventType.REJOINED + } else { + MemberEventType.JOINED + } + + fun insertEvent( + db: SQLiteDatabase, + uid: Long, + type: MemberEventType, + occurredAt: Instant?, + eventDate: LocalDate? = null, + timeKnown: Boolean = true, + observedAt: Instant, + precision: EvidencePrecision, + source: EvidenceSource, + note: String, + membershipPeriodId: Long? = null, + ): Long = db.insertOrThrow( + "member_events", + null, + ContentValues().apply { + put("uid", uid) + putNullableLong("membership_period_id", membershipPeriodId) + put("event_type", type.name) + putNullableLong("occurred_at", occurredAt?.toEpochMilli()) + putNullableLong("event_date", eventDate?.toEpochDay()) + put("time_known", if (timeKnown) 1 else 0) + put("observed_at", observedAt.toEpochMilli()) + put("precision", precision.name) + put("source", source.name) + put("note", note) + }, + ) + + /** JOINED and REJOINED describe the same boundary, so either one satisfies the other. */ + fun compatibleEventTypes(type: MemberEventType): Array = when (type) { + MemberEventType.JOINED, + MemberEventType.REJOINED, + -> arrayOf(MemberEventType.JOINED.name, MemberEventType.REJOINED.name) + else -> arrayOf(type.name, type.name) + } + + fun insertSnapshotBoundaryEvent( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + type: MemberEventType, + observedAt: Instant, + source: EvidenceSource, + ) { + val compatibleTypes = compatibleEventTypes(type) + val exists = db.exists( + "SELECT 1 FROM member_events " + + "WHERE membership_period_id = ? AND event_type IN (?, ?) LIMIT 1", + membershipPeriodId, + compatibleTypes[0], + compatibleTypes[1], + ) + if (exists) return + insertEvent( + db = db, + uid = uid, + type = type, + occurredAt = null, + observedAt = observedAt, + precision = EvidencePrecision.INFERRED, + source = source, + note = memberName, + membershipPeriodId = membershipPeriodId, + ) + } + + /** Deletes the selected events after detaching every note and activity fact citing them. */ + fun deleteEvents(db: SQLiteDatabase, selection: String, arguments: Array) { + val selectedIds = "SELECT id FROM member_events WHERE $selection" + db.delete("weekly_notes", "is_automatic = 1 AND event_id IN ($selectedIds)", arguments) + db.update( + "weekly_notes", + ContentValues().apply { putNull("event_id") }, + "event_id IN ($selectedIds)", + arguments, + ) + db.update( + "platoon_activity", + ContentValues().apply { putNull("member_event_id") }, + "member_event_id IN ($selectedIds)", + arguments, + ) + db.delete("member_events", 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()) + deleteEvents(db, "membership_period_id = ?", id) + return db.delete("membership_periods", "id = ?", id) == 1 + } + + /** + * Repairs the derived active flag while upgrading databases created before schema v13. + * Leaves every evidence period intact and derives only the current-state projection. + */ + fun synchronizeAllMembershipStates(db: SQLiteDatabase) { + db.execSQL( + """ + UPDATE members + SET is_active = CASE WHEN EXISTS ( + SELECT 1 + FROM membership_periods period + WHERE period.uid = members.uid AND period.left_at IS NULL + ) THEN 1 ELSE 0 END + """.trimIndent(), + ) + } +} 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 6582100..3cfb0fc 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -1,17 +1,18 @@ package dev.gf2log.app.management -import android.content.ContentValues import android.content.Context -import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import dev.gf2log.app.settings.GameTimeZonePreferences -import dev.gf2log.protocol.GuildMembersCsv import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.util.Locale +/** + * The single transactional write owner of one scoped management database. Every public method + * is serialized on this helper; the SQL lives in focused objects that receive the open + * [SQLiteDatabase] so one evidence ingest still commits or rolls back as a whole. + */ internal class PlatoonDatabase private constructor( context: Context, private val storageScope: PlatoonStorageScope, @@ -30,6 +31,9 @@ internal class PlatoonDatabase private constructor( ) : this(context, storageScope, databaseName) private val appContext = context.applicationContext + + private fun gameZone(): ZoneId = GameTimeZonePreferences.get(appContext, storageScope.storageId) + override fun onConfigure(db: SQLiteDatabase) { super.onConfigure(db) db.setForeignKeyConstraintsEnabled(true) @@ -38,358 +42,24 @@ internal class PlatoonDatabase private constructor( override fun onOpen(db: SQLiteDatabase) { super.onOpen(db) if (!db.isReadOnly) { - createPlatoonMaintenanceStateTable(db) - createPlatoonActivityRetentionIndex(db) - createWeeklyReportHistoryTables(db) - trimPlatoonActivity(db) - } - } - - override fun onCreate(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - captured_at INTEGER NOT NULL, - source_file TEXT UNIQUE, - game_version TEXT - ) - """.trimIndent(), - ) - db.execSQL("CREATE INDEX snapshots_captured_at ON snapshots(captured_at DESC)") - db.execSQL( - """ - 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) - ) - """.trimIndent(), - ) - db.execSQL( - """ - 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 '' - ) - """.trimIndent(), - ) - db.execSQL( - """ - CREATE TABLE membership_periods ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uid INTEGER NOT NULL REFERENCES members(uid), - joined_at INTEGER, - left_at INTEGER, - joined_date INTEGER, - left_date INTEGER, - joined_time_known INTEGER NOT NULL DEFAULT 1, - left_time_known INTEGER, - joined_precision TEXT NOT NULL, - left_precision TEXT, - joined_source TEXT NOT NULL, - left_source TEXT, - note TEXT NOT NULL DEFAULT '' - ) - """.trimIndent(), - ) - db.execSQL("CREATE INDEX membership_periods_uid ON membership_periods(uid, id DESC)") - db.execSQL( - """ - CREATE TABLE member_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uid INTEGER NOT NULL REFERENCES members(uid), - membership_period_id INTEGER REFERENCES membership_periods(id), - event_type TEXT NOT NULL, - occurred_at INTEGER, - event_date INTEGER, - time_known INTEGER NOT NULL DEFAULT 1, - observed_at INTEGER NOT NULL, - precision TEXT NOT NULL, - source TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '' - ) - """.trimIndent(), - ) - db.execSQL("CREATE INDEX member_events_time ON member_events(observed_at DESC)") - createPlatoonActivityTable(db) - db.execSQL( - """ - 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 - ) - """.trimIndent(), - ) - createWeeklyOverridesTable(db) - createPlatoonMaintenanceStateTable(db) - createWeeklyReportHistoryTables(db) - } - - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 10) { - db.execSQL("ALTER TABLE tenures RENAME TO membership_periods") - db.execSQL("DROP INDEX IF EXISTS tenures_uid") - db.execSQL( - "CREATE INDEX membership_periods_uid ON membership_periods(uid, id DESC)", - ) - if (oldVersion >= 6) { - migrateMembershipPeriodEventReference( - db = db, - hasCalendarColumns = oldVersion >= 9, - ) - } - } - val needsManualCalendarDateBackfill = oldVersion < 9 - if (oldVersion < 9) { - db.execSQL("ALTER TABLE membership_periods ADD COLUMN joined_date INTEGER") - db.execSQL("ALTER TABLE membership_periods ADD COLUMN left_date INTEGER") - db.execSQL( - "ALTER TABLE membership_periods ADD COLUMN joined_time_known INTEGER NOT NULL DEFAULT 1", - ) - db.execSQL("ALTER TABLE membership_periods ADD COLUMN left_time_known INTEGER") - db.execSQL("ALTER TABLE member_events ADD COLUMN event_date INTEGER") - db.execSQL( - "ALTER TABLE member_events ADD COLUMN time_known INTEGER NOT NULL DEFAULT 1", - ) - } - if (oldVersion < 2) { - val correctedPeriods = mutableListOf>() - db.query( - "weekly_notes", - arrayOf("id", "game_day"), - null, - null, - null, - null, - null, - ).use { cursor -> - while (cursor.moveToNext()) { - correctedPeriods += cursor.getLong(0) to - PlatoonPeriods.weekStart( - java.time.LocalDate.ofEpochDay(cursor.getLong(1)), - ).toEpochDay() - } - } - correctedPeriods.forEach { (id, periodStart) -> - db.update( - "weekly_notes", - ContentValues().apply { put("period_start", periodStart) }, - "id = ?", - arrayOf(id.toString()), - ) - } - } - if (oldVersion < 3) createWeeklyOverridesTable(db) - if (oldVersion < 4) { - db.execSQL("ALTER TABLE members ADD COLUMN custom_name TEXT") + PlatoonDatabaseSchema.ensureMaintenanceObjects(db) + ActivityIngestion.trimPlatoonActivity(db) } - if (oldVersion < 5) { - val historicalNames = mutableListOf>() - db.query( - "weekly_notes", - arrayOf("event_id", "text"), - "is_automatic = 1 AND event_id IS NOT NULL", - null, - null, - null, - "id", - ).use { cursor -> - while (cursor.moveToNext()) { - val parts = cursor.getString(1).split(':', limit = 3) - if (parts.size == 3 && parts[1].isNotBlank()) { - historicalNames += cursor.getLong(0) to parts[1] - } - } - } - historicalNames.forEach { (eventId, name) -> - db.update( - "member_events", - ContentValues().apply { put("note", name) }, - "id = ? AND note = ''", - arrayOf(eventId.toString()), - ) - } - } - if (oldVersion < 6) { - db.execSQL("ALTER TABLE member_events ADD COLUMN membership_period_id INTEGER REFERENCES membership_periods(id)") - createPlatoonActivityTable(db) - db.delete("weekly_notes", "is_automatic = 1", null) - linkLegacyMembershipPeriodEvents(db) - } - if (oldVersion < 7) { - backfillSnapshotMembershipPeriodEvents(db) - } - if (needsManualCalendarDateBackfill) { - backfillManualCalendarDates( - db, - GameTimeZonePreferences.get(appContext, storageScope.storageId), - ) - } - if (oldVersion < 11) { - createPlatoonMaintenanceStateTable(db) - createPlatoonActivityRetentionIndex(db) - } - if (oldVersion < 12) createWeeklyReportHistoryTables(db) - if (oldVersion < 13) synchronizeAllMembershipStates(db) - } - - private fun createWeeklyReportHistoryTables(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS weekly_report_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - period_start INTEGER NOT NULL, - recorded_at INTEGER NOT NULL, - fingerprint TEXT NOT NULL, - report_blob BLOB NOT NULL - ) - """.trimIndent(), - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS weekly_report_history_period " + - "ON weekly_report_history(period_start, recorded_at DESC, id DESC)", - ) - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS weekly_report_history_state ( - period_start INTEGER PRIMARY KEY, - active_history_id INTEGER REFERENCES weekly_report_history(id) ON DELETE SET NULL - ) - """.trimIndent(), - ) } - // Function Name: migrateMembershipPeriodEventReference - // Description: - // - Rebuilds the membership-event table with the clearer membership-period identifier. - // - Rebuilds dependent tables so every foreign key targets the replacement event table. - // Parameters: - // - db: Database being upgraded inside SQLiteOpenHelper's transaction. - // - hasCalendarColumns: Whether the source event table already contains schema-v9 dates. - // Returns: - // - Returns after all rows, IDs, and foreign-key relationships have been preserved. - private fun migrateMembershipPeriodEventReference( - db: SQLiteDatabase, - hasCalendarColumns: Boolean, - ) { - db.execSQL("ALTER TABLE member_events RENAME TO member_events_legacy_v10") - db.execSQL("ALTER TABLE weekly_notes RENAME TO weekly_notes_legacy_v10") - db.execSQL("ALTER TABLE platoon_activity RENAME TO platoon_activity_legacy_v10") - db.execSQL("DROP INDEX IF EXISTS member_events_time") - db.execSQL("DROP INDEX IF EXISTS platoon_activity_member_time") - db.execSQL("DROP INDEX IF EXISTS platoon_activity_action_time") - db.execSQL("DROP INDEX IF EXISTS platoon_activity_exact_identity") - db.execSQL("DROP INDEX IF EXISTS platoon_activity_unresolved_identity") + override fun onCreate(db: SQLiteDatabase) = PlatoonDatabaseSchema.create(db) - val calendarColumns = if (hasCalendarColumns) { - "event_date INTEGER, time_known INTEGER NOT NULL DEFAULT 1," - } else { - "" - } - db.execSQL( - """ - CREATE TABLE member_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uid INTEGER NOT NULL REFERENCES members(uid), - membership_period_id INTEGER REFERENCES membership_periods(id), - event_type TEXT NOT NULL, - occurred_at INTEGER, - $calendarColumns - observed_at INTEGER NOT NULL, - precision TEXT NOT NULL, - source TEXT NOT NULL, - note TEXT NOT NULL DEFAULT '' - ) - """.trimIndent(), - ) - val calendarNames = if (hasCalendarColumns) ", event_date, time_known" else "" - db.execSQL( - """ - INSERT INTO member_events( - id, uid, membership_period_id, event_type, occurred_at$calendarNames, - observed_at, precision, source, note - ) - SELECT id, uid, tenure_id, event_type, occurred_at$calendarNames, - observed_at, precision, source, note - FROM member_events_legacy_v10 - """.trimIndent(), - ) - db.execSQL("CREATE INDEX member_events_time ON member_events(observed_at DESC)") - - db.execSQL( - """ - 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 - ) - """.trimIndent(), - ) - db.execSQL( - """ - INSERT INTO weekly_notes(id, period_start, game_day, text, event_id, is_automatic) - SELECT id, period_start, game_day, text, event_id, is_automatic - FROM weekly_notes_legacy_v10 - """.trimIndent(), - ) - - createPlatoonActivityTable(db) - db.execSQL( - """ - INSERT OR IGNORE INTO platoon_activity( - id, occurred_at, action_id, kind, member_name, captured_at, - resolved_uid, resolution, member_event_id - ) - SELECT id, occurred_at, action_id, kind, member_name, captured_at, - resolved_uid, resolution, member_event_id - FROM platoon_activity_legacy_v10 - ORDER BY CASE WHEN member_event_id IS NULL THEN 1 ELSE 0 END, id - """.trimIndent(), - ) - db.execSQL("DROP TABLE weekly_notes_legacy_v10") - db.execSQL("DROP TABLE platoon_activity_legacy_v10") - db.execSQL("DROP TABLE member_events_legacy_v10") - } + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = + PlatoonDatabaseMigrations.upgrade(db, oldVersion, ::gameZone) /** Returns true only when this database contains user-visible management state. */ internal fun hasManagementData(): Boolean { val db = readableDatabase - return MANAGEMENT_DATA_TABLES.any { table -> count(db, table) > 0L } + return MANAGEMENT_DATA_TABLES.any { table -> PlatoonReadQueries.count(db, table) > 0L } } @Synchronized - internal fun runInTransaction(block: () -> T): T { - val db = writableDatabase - db.beginTransaction() - try { - val result = block() - db.setTransactionSuccessful() - return result - } finally { - db.endTransaction() - } - } + internal fun runInTransaction(block: () -> T): T = writableDatabase.inTransaction(block) @Synchronized fun ingestSnapshot( @@ -397,1496 +67,43 @@ internal class PlatoonDatabase private constructor( source: EvidenceSource, historicalOnly: Boolean = false, deferHistoricalMembershipReconciliation: Boolean = false, - ): SnapshotIngestResult { - require(snapshot.members.isNotEmpty()) { "A Platoon snapshot cannot be empty" } - require(snapshot.members.size <= GuildMembersCsv.MAX_ROSTER_MEMBERS) { - "A Platoon snapshot exceeds the member limit" - } - require(snapshot.members.all { it.name.length <= GuildMembersCsv.MAX_MEMBER_NAME_CHARS }) { - "A Platoon snapshot contains an oversized member name" - } - require(snapshot.members.map(SnapshotMember::uid).distinct().size == snapshot.members.size) { - "A Platoon snapshot cannot contain duplicate UIDs" - } - - val db = writableDatabase - db.beginTransaction() - try { - if (snapshot.sourceFile != null && sourceFileExists(db, snapshot.sourceFile)) { - return SnapshotIngestResult.duplicate() - } - - val hasPriorSnapshot = count(db, "snapshots") > 0 - val priorCapturedAt = latestSnapshotCapturedAt(db) - if (historicalOnly) { - require(priorCapturedAt != null && !snapshot.capturedAt.isAfter(priorCapturedAt)) { - "Historical snapshots must not be newer than current structured data" - } - val snapshotId = insertSnapshotRows(db, snapshot) - if (!deferHistoricalMembershipReconciliation) { - reconcileSnapshotMembershipHistory(db) - } - db.setTransactionSuccessful() - return SnapshotIngestResult.historical(snapshotId) - } - val known = readKnownMembers(db) - val changes = SnapshotReconciler.reconcile( - known = known, - incoming = snapshot.members, - hasPriorSnapshot = hasPriorSnapshot, - ) - val snapshotId = db.insertOrThrow( - "snapshots", - null, - ContentValues().apply { - put("captured_at", snapshot.capturedAt.toEpochMilli()) - put("source_file", snapshot.sourceFile) - put("game_version", snapshot.gameVersion) - }, - ) - - snapshot.members.forEach { member -> - insertSnapshotMember(db, snapshotId, member) - upsertMember(db, member, snapshot.capturedAt) - } - - if (hasPriorSnapshot) { - val changedNameCounts = ( - changes.joined.map(SnapshotMember::name) + - changes.rejoined.map(SnapshotMember::name) + - changes.left.map(SnapshotReconciler.KnownMember::name) - ).groupingBy { it.lowercase(Locale.ROOT) }.eachCount() - val rosterNameUidCounts = ( - known.map { it.uid to it.name } + - snapshot.members.map { it.uid to it.name } - ).groupBy { it.second.lowercase(Locale.ROOT) } - .mapValues { (_, identities) -> identities.map { it.first }.distinct().size } - changes.joined.forEach { member -> - recordSnapshotJoin( - db = db, - member = member, - type = MemberEventType.JOINED, - observedAt = snapshot.capturedAt, - priorCapturedAt = priorCapturedAt, - source = source, - nameIsUnique = changedNameCounts[member.name.lowercase(Locale.ROOT)] == 1 && - rosterNameUidCounts[member.name.lowercase(Locale.ROOT)] == 1, - ) - } - changes.rejoined.forEach { member -> - recordSnapshotJoin( - db = db, - member = member, - type = MemberEventType.REJOINED, - observedAt = snapshot.capturedAt, - priorCapturedAt = priorCapturedAt, - source = source, - nameIsUnique = changedNameCounts[member.name.lowercase(Locale.ROOT)] == 1 && - rosterNameUidCounts[member.name.lowercase(Locale.ROOT)] == 1, - ) - } - changes.left.forEach { member -> - val exactMembershipPeriodId = findRosterConfirmedExactMembershipPeriod( - db = db, - uid = member.uid, - boundary = MembershipBoundary.WITHDRAW, - from = priorCapturedAt, - observedAt = snapshot.capturedAt, - ) - val membershipPeriodId = exactMembershipPeriodId ?: closeLatestMembershipPeriod( - db = db, - uid = member.uid, - leftAt = snapshot.capturedAt, - precision = EvidencePrecision.INFERRED, - source = source, - ) - markInactive(db, member.uid, snapshot.capturedAt) - if (exactMembershipPeriodId == null) { - insertSnapshotBoundaryEvent( - db, - membershipPeriodId, - member.uid, - member.name, - MemberEventType.LEFT, - snapshot.capturedAt, - source, - ) - correlateMembershipBoundary( - db = db, - membershipPeriodId = membershipPeriodId, - member = SnapshotMember(member.uid, member.name, 0, 0, 0, 0, 0, 0), - type = MemberEventType.LEFT, - boundary = MembershipBoundary.WITHDRAW, - observedAt = snapshot.capturedAt, - from = priorCapturedAt, - nameIsUnique = - changedNameCounts[member.name.lowercase(Locale.ROOT)] == 1 && - rosterNameUidCounts[ - member.name.lowercase(Locale.ROOT) - ] == 1, - ) - } - } - changes.renamed.forEach { rename -> - insertEvent( - db = db, - uid = rename.uid, - type = MemberEventType.RENAMED, - occurredAt = snapshot.capturedAt, - observedAt = snapshot.capturedAt, - precision = EvidencePrecision.INFERRED, - source = source, - note = "${rename.oldName} -> ${rename.newName}", - ) - } - } - // A roster is authoritative for current presence. Repair any missing open - // membershipPeriod left by an incomplete historical Updates feed. - snapshot.members.forEach { member -> - ensureRosterActiveMembershipPeriod(db, member.uid, source) - } - - trimPlatoonActivity(db) - resolveUnresolvedActivityUids(db) - db.setTransactionSuccessful() - return SnapshotIngestResult( - snapshotId = snapshotId, - duplicate = false, - initialRoster = changes.initialRoster, - joined = changes.joined.size, - rejoined = changes.rejoined.size, - left = changes.left.size, - renamed = changes.renamed.size, - ) - } finally { - db.endTransaction() - } - } + ): SnapshotIngestResult = SnapshotIngestion.ingest( + writableDatabase, + snapshot, + source, + historicalOnly, + deferHistoricalMembershipReconciliation, + ) - // Function Name: reconcileSnapshotMembershipHistory - // Description: - // - Replays every retained roster in chronological order after late historical imports. - // - Rebuilds only weak snapshot-derived membership periods and preserves manual or exact evidence. - // - Creates inactive member records for people found only in historical rosters. - // Parameters: - // - None. - // Returns: - // - Unit after atomically reconciling member identities and membership periods. + /** Replays every retained roster after late historical imports; see [RosterHistoryReplay]. */ @Synchronized fun reconcileSnapshotMembershipHistory() { val db = writableDatabase - db.beginTransaction() - try { - reconcileSnapshotMembershipHistory(db) - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } - - private fun reconcileSnapshotMembershipHistory(db: SQLiteDatabase) { - val snapshots = readRosterFrames(db) - if (snapshots.isEmpty()) return - - ensureHistoricalMembers(db, snapshots) - removeWeakSnapshotMembershipPeriods(db) - - val allUids = snapshots.flatMap { frame -> frame.members.keys }.toSortedSet() - allUids.forEach { uid -> - val runs = buildRosterPresenceRuns(snapshots, uid) - runs.forEach { run -> - if (!reconcilePreservedMembershipPeriodOverlap(db, uid, run)) { - insertRosterPresenceRun(db, uid, run) - } - } - } - resolveUnresolvedActivityUids(db) - } - - private fun readRosterFrames(db: SQLiteDatabase): List { - val frames = linkedMapOf>() - db.rawQuery( - """ - SELECT s.id, s.captured_at, sm.uid, sm.name, sm.level - FROM snapshots s - JOIN snapshot_members sm ON sm.snapshot_id = s.id - ORDER BY s.captured_at, s.id, sm.uid - """.trimIndent(), - null, - ).use { cursor -> - while (cursor.moveToNext()) { - val snapshotId = cursor.getLong(0) - val capturedAt = Instant.ofEpochMilli(cursor.getLong(1)) - frames.getOrPut(snapshotId) { linkedMapOf() }[cursor.getLong(2)] = - RosterIdentity(cursor.getString(3), cursor.getInt(4), capturedAt) - } - } - return frames.map { (_, members) -> - RosterFrame(members.values.first().capturedAt, members) - } - } - - private fun ensureHistoricalMembers(db: SQLiteDatabase, snapshots: List) { - val latestFrame = snapshots.last() - snapshots.flatMap { frame -> - frame.members.map { (uid, identity) -> Triple(uid, identity, frame.capturedAt) } - }.groupBy { it.first }.forEach { (uid, observations) -> - val firstSeenAt = observations.minOf { it.third } - val latest = observations.maxBy { it.third } - if (!memberExists(db, uid)) { - db.insertOrThrow( - "members", - null, - ContentValues().apply { - put("uid", uid) - put("current_name", latest.second.name) - put("current_level", latest.second.level) - put("is_active", if (uid in latestFrame.members) 1 else 0) - put("first_seen_at", firstSeenAt.toEpochMilli()) - put("last_seen_at", latest.third.toEpochMilli()) - put("note", "") - }, - ) - } else { - db.execSQL( - "UPDATE members SET first_seen_at = MIN(first_seen_at, ?) WHERE uid = ?", - arrayOf(firstSeenAt.toEpochMilli(), uid), - ) - } - } - } - - private fun removeWeakSnapshotMembershipPeriods(db: SQLiteDatabase) { - val sourceNames = SNAPSHOT_EVENT_SOURCES.map(EvidenceSource::name) - val weakIds = db.rawQuery( - """ - SELECT id - FROM membership_periods - WHERE joined_source IN (?, ?) - AND (left_source IS NULL OR left_source IN (?, ?)) - AND joined_precision IN (?, ?) - AND (left_precision IS NULL OR left_precision = ?) - AND note = '' - """.trimIndent(), - arrayOf( - sourceNames[0], - sourceNames[1], - sourceNames[0], - sourceNames[1], - EvidencePrecision.UNKNOWN.name, - EvidencePrecision.INFERRED.name, - EvidencePrecision.INFERRED.name, - ), - ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getLong(0)) } } - - weakIds.forEach { membershipPeriodId -> - db.delete( - "weekly_notes", - "is_automatic = 1 AND event_id IN (SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.update( - "weekly_notes", - ContentValues().apply { putNull("event_id") }, - "is_automatic = 0 AND event_id IN (SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.update( - "platoon_activity", - ContentValues().apply { putNull("member_event_id") }, - "member_event_id IN (SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.delete( - "member_events", - "membership_period_id = ?", - arrayOf(membershipPeriodId.toString()), - ) - db.delete("membership_periods", "id = ?", arrayOf(membershipPeriodId.toString())) - } - } - - private fun buildRosterPresenceRuns( - snapshots: List, - uid: Long, - ): List { - val runs = mutableListOf() - var index = 0 - while (index < snapshots.size) { - if (uid !in snapshots[index].members) { - index += 1 - continue - } - val firstIndex = index - while (index + 1 < snapshots.size && uid in snapshots[index + 1].members) index += 1 - val lastIndex = index - val first = snapshots[firstIndex] - val last = snapshots[lastIndex] - runs += RosterPresenceRun( - firstSeenAt = first.capturedAt, - lastSeenAt = last.capturedAt, - joinedAt = first.capturedAt.takeIf { firstIndex > 0 }, - leftAt = snapshots.getOrNull(lastIndex + 1)?.capturedAt, - name = first.members.getValue(uid).name, - ) - index += 1 - } - return runs - } - - // Function Name: reconcilePreservedMembershipPeriodOverlap - // Description: - // - Preserves exact, manual, or user-annotated membership periods during roster replay. - // - Closes every overlapping open preserved period at an observed roster absence. - // - Allows a later roster-presence run to become a separate inferred rejoin period. - // Parameters: - // - db: Writable database participating in the replay transaction. - // - uid: Stable member identity whose roster run is being reconciled. - // - run: One contiguous roster-presence run bounded by observed absence when known. - // Returns: - // - True when a preserved period covers this run; false when a new weak run is required. - private fun reconcilePreservedMembershipPeriodOverlap( - db: SQLiteDatabase, - uid: Long, - run: RosterPresenceRun, - ): Boolean { - val overlaps = db.rawQuery( - """ - SELECT id, joined_at, joined_precision, joined_source, - left_at, left_precision, left_source - FROM membership_periods - WHERE uid = ? - AND (joined_at IS NULL OR joined_at <= ?) - AND (left_at IS NULL OR left_at > ?) - ORDER BY COALESCE(joined_at, -9223372036854775808), id - """.trimIndent(), - arrayOf( - uid.toString(), - run.lastSeenAt.toEpochMilli().toString(), - run.firstSeenAt.toEpochMilli().toString(), - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - PreservedMembershipPeriodOverlap( - id = cursor.getLong(0), - joinedAt = cursor.getNullableLong(1)?.let(Instant::ofEpochMilli), - joinedPrecision = EvidencePrecision.valueOf(cursor.getString(2)), - joinedSource = EvidenceSource.valueOf(cursor.getString(3)), - leftAt = cursor.getNullableLong(4)?.let(Instant::ofEpochMilli), - leftPrecision = cursor.getNullableString(5) - ?.let(EvidencePrecision::valueOf), - leftSource = cursor.getNullableString(6) - ?.let(EvidenceSource::valueOf), - ), - ) - } - } - } - overlaps.forEach { membershipPeriod -> - val joinedAt = membershipPeriod.joinedAt - if ( - joinedAt != null && - run.firstSeenAt.isBefore(joinedAt) && - isWeakSnapshotBoundary( - membershipPeriod.joinedPrecision, - membershipPeriod.joinedSource, - ) - ) { - val updated = db.update( - "membership_periods", - ContentValues().apply { - put("joined_at", run.firstSeenAt.toEpochMilli()) - putNull("joined_date") - put("joined_time_known", 1) - put("joined_precision", EvidencePrecision.INFERRED.name) - put("joined_source", EvidenceSource.LEGACY_IMPORT.name) - }, - "id = ?", - arrayOf(membershipPeriod.id.toString()), - ) - if (updated == 1) { - updateSnapshotBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriod.id, - uid = uid, - memberName = run.name, - type = if (hasEarlierMembershipPeriod(db, uid, membershipPeriod.id)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - }, - observedAt = run.firstSeenAt, - ) - } - } - - val observedLeftAt = run.leftAt - val weakLeftBoundary = membershipPeriod.leftPrecision != null && - membershipPeriod.leftSource != null && - isWeakSnapshotBoundary( - membershipPeriod.leftPrecision, - membershipPeriod.leftSource, - ) - if ( - observedLeftAt != null && - ( - membershipPeriod.leftAt == null || - (weakLeftBoundary && membershipPeriod.leftAt != observedLeftAt) - ) - ) { - val updated = db.update( - "membership_periods", - ContentValues().apply { - put("left_at", observedLeftAt.toEpochMilli()) - putNull("left_date") - put("left_time_known", 1) - put("left_precision", EvidencePrecision.INFERRED.name) - put("left_source", EvidenceSource.LEGACY_IMPORT.name) - }, - "id = ?", - arrayOf(membershipPeriod.id.toString()), - ) - if (updated == 1) { - updateSnapshotBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriod.id, - uid = uid, - memberName = run.name, - type = MemberEventType.LEFT, - observedAt = observedLeftAt, - ) - } - } else if ( - observedLeftAt == null && - membershipPeriod.leftAt != null && - weakLeftBoundary && - !run.lastSeenAt.isBefore(membershipPeriod.leftAt) - ) { - db.update( - "membership_periods", - ContentValues().apply { - putNull("left_at") - putNull("left_date") - putNull("left_time_known") - putNull("left_precision") - putNull("left_source") - }, - "id = ?", - arrayOf(membershipPeriod.id.toString()), - ) - deleteSnapshotBoundaryEvents( - db = db, - membershipPeriodId = membershipPeriod.id, - type = MemberEventType.LEFT, - ) - } - } - return overlaps.isNotEmpty() - } - - private fun isWeakSnapshotBoundary( - precision: EvidencePrecision, - source: EvidenceSource, - ): Boolean = precision in setOf(EvidencePrecision.UNKNOWN, EvidencePrecision.INFERRED) && - source in SNAPSHOT_EVENT_SOURCES - - private fun updateSnapshotBoundaryEvent( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - memberName: String, - type: MemberEventType, - observedAt: Instant, - ) { - val compatibleTypes = when (type) { - MemberEventType.JOINED, - MemberEventType.REJOINED, - -> arrayOf(MemberEventType.JOINED.name, MemberEventType.REJOINED.name) - else -> arrayOf(type.name, type.name) - } - val updated = db.update( - "member_events", - ContentValues().apply { - put("event_type", type.name) - putNull("occurred_at") - putNull("event_date") - put("time_known", 1) - put("observed_at", observedAt.toEpochMilli()) - put("precision", EvidencePrecision.INFERRED.name) - put("source", EvidenceSource.LEGACY_IMPORT.name) - put("note", memberName) - }, - "membership_period_id = ? AND event_type IN (?, ?) " + - "AND source IN (?, ?)", - arrayOf( - membershipPeriodId.toString(), - compatibleTypes[0], - compatibleTypes[1], - EvidenceSource.SNAPSHOT.name, - EvidenceSource.LEGACY_IMPORT.name, - ), - ) - if (updated == 0) { - insertSnapshotBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriodId, - uid = uid, - memberName = memberName, - type = type, - observedAt = observedAt, - source = EvidenceSource.LEGACY_IMPORT, - ) - } - } - - private fun deleteSnapshotBoundaryEvents( - db: SQLiteDatabase, - membershipPeriodId: Long, - type: MemberEventType, - ) { - val eventSelection = - "membership_period_id = ? AND event_type = ? AND source IN (?, ?)" - val eventArguments = arrayOf( - membershipPeriodId.toString(), - type.name, - EvidenceSource.SNAPSHOT.name, - EvidenceSource.LEGACY_IMPORT.name, - ) - db.delete( - "weekly_notes", - "is_automatic = 1 AND event_id IN " + - "(SELECT id FROM member_events WHERE $eventSelection)", - eventArguments, - ) - db.update( - "weekly_notes", - ContentValues().apply { putNull("event_id") }, - "event_id IN (SELECT id FROM member_events WHERE $eventSelection)", - eventArguments, - ) - db.update( - "platoon_activity", - ContentValues().apply { putNull("member_event_id") }, - "member_event_id IN (SELECT id FROM member_events WHERE $eventSelection)", - eventArguments, - ) - db.delete("member_events", eventSelection, eventArguments) - } - - private fun insertRosterPresenceRun( - db: SQLiteDatabase, - uid: Long, - run: RosterPresenceRun, - ) { - val membershipPeriodId = db.insertOrThrow( - "membership_periods", - null, - ContentValues().apply { - put("uid", uid) - putNullableLong("joined_at", run.joinedAt?.toEpochMilli()) - putNullableLong("left_at", run.leftAt?.toEpochMilli()) - put("joined_time_known", 1) - putNullableInt("left_time_known", run.leftAt?.let { 1 }) - put( - "joined_precision", - if (run.joinedAt == null) EvidencePrecision.UNKNOWN.name else EvidencePrecision.INFERRED.name, - ) - put( - "left_precision", - run.leftAt?.let { EvidencePrecision.INFERRED.name }, - ) - put("joined_source", EvidenceSource.LEGACY_IMPORT.name) - put("left_source", run.leftAt?.let { EvidenceSource.LEGACY_IMPORT.name }) - }, - ) - if (run.joinedAt != null) { - insertSnapshotBoundaryEvent( - db, - membershipPeriodId, - uid, - run.name, - if (hasEarlierMembershipPeriod(db, uid, membershipPeriodId)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - }, - run.joinedAt, - EvidenceSource.LEGACY_IMPORT, - ) - } - if (run.leftAt != null) { - insertSnapshotBoundaryEvent( - db, - membershipPeriodId, - uid, - run.name, - MemberEventType.LEFT, - run.leftAt, - EvidenceSource.LEGACY_IMPORT, - ) - } + db.inTransaction { RosterHistoryReplay.reconcile(db) } } @Synchronized - fun latestSnapshotIdentity(): Pair? = readableDatabase.rawQuery( - "SELECT captured_at, source_file FROM snapshots " + - "ORDER BY captured_at DESC, source_file DESC, id DESC LIMIT 1", - null, - ).use { cursor -> - if (cursor.moveToFirst()) { - Instant.ofEpochMilli(cursor.getLong(0)) to cursor.getNullableString(1) - } else { - null - } - } + fun latestSnapshotIdentity(): Pair? = + PlatoonReadQueries.latestSnapshotIdentity(readableDatabase) @Synchronized - fun snapshotSourceFiles(): Set = readableDatabase.rawQuery( - "SELECT source_file FROM snapshots WHERE source_file IS NOT NULL", - null, - ).use { cursor -> - buildSet { - while (cursor.moveToNext()) add(cursor.getString(0)) - } - } - - private fun insertSnapshotRows(db: SQLiteDatabase, snapshot: PlatoonSnapshot): Long { - val snapshotId = db.insertOrThrow( - "snapshots", - null, - ContentValues().apply { - put("captured_at", snapshot.capturedAt.toEpochMilli()) - put("source_file", snapshot.sourceFile) - put("game_version", snapshot.gameVersion) - }, - ) - snapshot.members.forEach { member -> insertSnapshotMember(db, snapshotId, member) } - return snapshotId - } + fun snapshotSourceFiles(): Set = PlatoonReadQueries.snapshotSourceFiles(readableDatabase) @Synchronized fun ingestPlatoonActivity( observations: List, capturedAt: Instant, - ): ActivityIngestResult { - val acceptedObservations = PlatoonObservationPolicy.activity(observations) - if (acceptedObservations.isEmpty()) return ActivityIngestResult(0, 0, 0) - val db = writableDatabase - db.beginTransaction() - try { - var inserted = 0 - var resolved = 0 - acceptedObservations.forEach { observation -> - val resolvedUid = resolveUidForName( - db, - observation.memberName, - observation.occurredAt, - ) - val result = db.insertWithOnConflict( - "platoon_activity", - null, - ContentValues().apply { - put("occurred_at", observation.occurredAt.toEpochMilli()) - put("action_id", observation.actionId) - put("kind", observation.kind) - put("member_name", observation.memberName) - put("captured_at", capturedAt.toEpochMilli()) - putNullableLong("resolved_uid", resolvedUid) - put( - "resolution", - if (resolvedUid == null) { - ActivityResolution.UNRESOLVED.name - } else { - ActivityResolution.UNIQUE_ROSTER_NAME.name - }, - ) - }, - SQLiteDatabase.CONFLICT_IGNORE, - ) - if (result != -1L) { - inserted += 1 - if (resolvedUid != null) resolved += 1 - } - } - trimPlatoonActivity(db) - resolveUnresolvedActivityUids(db) - reconcileInferredMembershipBoundaries(db) - db.setTransactionSuccessful() - return ActivityIngestResult(acceptedObservations.size, inserted, resolved) - } finally { - db.endTransaction() - } - } + ): ActivityIngestResult = ActivityIngestion.ingest(writableDatabase, observations, capturedAt) @Synchronized fun ingestPlatoonUpdates( observations: List, capturedAt: Instant, - ): UpdatesIngestResult { - val acceptedObservations = PlatoonObservationPolicy.updates(observations) - if (acceptedObservations.isEmpty()) return UpdatesIngestResult(0, 0, 0) - val db = writableDatabase - db.beginTransaction() - try { - var membershipEvents = 0 - var patrolFacts = 0 - acceptedObservations.forEach { observation -> - observation.members.forEach { - ensureUpdateMember(db, it, observation.occurredAt) - } - val affectedMembers = PlatoonUpdateSemantics.affectedMembers( - kind = observation.kind, - members = observation.members, - ) - when (PlatoonUpdateSemantics.effect(observation.kind)) { - PlatoonUpdateEffect.JOIN -> affectedMembers.forEach { member -> - if (applyExactUpdateBoundary( - db = db, - member = member, - type = MemberEventType.JOINED, - boundary = MembershipBoundary.JOIN, - occurredAt = observation.occurredAt, - observedAt = capturedAt, - ) - ) { - membershipEvents += 1 - } - synchronizeCurrentMembershipFromUpdate( - db, - member.uid, - ) - } - PlatoonUpdateEffect.WITHDRAW -> affectedMembers.forEach { member -> - if (applyExactUpdateBoundary( - db = db, - member = member, - type = MemberEventType.LEFT, - boundary = MembershipBoundary.WITHDRAW, - occurredAt = observation.occurredAt, - observedAt = capturedAt, - ) - ) { - membershipEvents += 1 - } - synchronizeCurrentMembershipFromUpdate( - db, - member.uid, - ) - } - PlatoonUpdateEffect.REMOVED -> affectedMembers.forEach { member -> - if (applyExactUpdateBoundary( - db = db, - member = member, - type = MemberEventType.REMOVED, - boundary = MembershipBoundary.WITHDRAW, - occurredAt = observation.occurredAt, - observedAt = capturedAt, - ) - ) { - membershipEvents += 1 - } - synchronizeCurrentMembershipFromUpdate( - db, - member.uid, - ) - } - PlatoonUpdateEffect.DAILY_PATROL -> affectedMembers.forEach { member -> - val inserted = db.insertWithOnConflict( - "platoon_activity", - null, - ContentValues().apply { - put("occurred_at", observation.occurredAt.toEpochMilli()) - put("action_id", DAILY_PATROL_REWARD_ACTION_ID) - put("kind", observation.kind) - put("member_name", member.name) - put("captured_at", capturedAt.toEpochMilli()) - put("resolved_uid", member.uid) - put("resolution", ActivityResolution.EXACT_UPDATE.name) - }, - SQLiteDatabase.CONFLICT_IGNORE, - ) - if (inserted != -1L) patrolFacts += 1 - } - PlatoonUpdateEffect.IGNORE -> Unit - } - } - trimPlatoonActivity(db) - db.setTransactionSuccessful() - return UpdatesIngestResult(acceptedObservations.size, membershipEvents, patrolFacts) - } finally { - db.endTransaction() - } - } - - private fun synchronizeCurrentMembershipFromUpdate( - db: SQLiteDatabase, - uid: Long, - ) { - val latestSnapshotAt = latestSnapshotCapturedAt(db) - val snapshotFilter = if (latestSnapshotAt == null) "" else "AND occurred_at > ?" - val arguments = mutableListOf( - uid.toString(), - EvidenceSource.GAME_UPDATES.name, - EvidencePrecision.EXACT.name, - MemberEventType.JOINED.name, - MemberEventType.REJOINED.name, - MemberEventType.LEFT.name, - MemberEventType.REMOVED.name, - ).apply { - latestSnapshotAt?.let { add(it.toEpochMilli().toString()) } - } - val latestBoundary = db.rawQuery( - """ - SELECT event_type, note, occurred_at - FROM member_events - WHERE uid = ? - AND source = ? - AND precision = ? - AND event_type IN (?, ?, ?, ?) - AND occurred_at IS NOT NULL - $snapshotFilter - ORDER BY occurred_at DESC, - CASE event_type - WHEN '${MemberEventType.REMOVED.name}' THEN 0 - WHEN '${MemberEventType.LEFT.name}' THEN 1 - WHEN '${MemberEventType.REJOINED.name}' THEN 2 - ELSE 3 - END, - id DESC - LIMIT 1 - """.trimIndent(), - arguments.toTypedArray(), - ).use { cursor -> - if (!cursor.moveToFirst()) return - LatestMembershipBoundary( - type = MemberEventType.valueOf(cursor.getString(0)), - name = cursor.getString(1), - occurredAt = Instant.ofEpochMilli(cursor.getLong(2)), - ) - } - db.update( - "members", - ContentValues().apply { - put("current_name", latestBoundary.name) - put( - "is_active", - if (latestBoundary.type in setOf( - MemberEventType.JOINED, - MemberEventType.REJOINED, - ) - ) { - 1 - } else { - 0 - }, - ) - put("last_seen_at", latestBoundary.occurredAt.toEpochMilli()) - }, - "uid = ?", - arrayOf(uid.toString()), - ) - } - - private data class LatestMembershipBoundary( - val type: MemberEventType, - val name: String, - val occurredAt: Instant, - ) - - private fun ensureUpdateMember( - db: SQLiteDatabase, - member: PlatoonUpdateMemberObservation, - observedAt: Instant, - ) { - if (memberExists(db, member.uid)) return - db.insertOrThrow( - "members", - null, - ContentValues().apply { - put("uid", member.uid) - put("current_name", member.name) - put("current_level", 0) - put("is_active", 0) - put("first_seen_at", observedAt.toEpochMilli()) - put("last_seen_at", observedAt.toEpochMilli()) - put("note", "") - }, - ) - } - - private fun recordSnapshotJoin( - db: SQLiteDatabase, - member: SnapshotMember, - type: MemberEventType, - observedAt: Instant, - priorCapturedAt: Instant?, - source: EvidenceSource, - nameIsUnique: Boolean, - ) { - val exactMembershipPeriodId = findRosterConfirmedExactMembershipPeriod( - db = db, - uid = member.uid, - boundary = MembershipBoundary.JOIN, - from = priorCapturedAt, - observedAt = observedAt, - ) - if (exactMembershipPeriodId != null) return - - val membershipPeriodId = insertMembershipPeriod( - db = db, - uid = member.uid, - joinedAt = observedAt, - precision = EvidencePrecision.INFERRED, - source = source, - ) - insertSnapshotBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriodId, - uid = member.uid, - memberName = member.name, - type = type, - observedAt = observedAt, - source = source, - ) - correlateMembershipBoundary( - db = db, - membershipPeriodId = membershipPeriodId, - member = member, - type = type, - boundary = MembershipBoundary.JOIN, - observedAt = observedAt, - from = priorCapturedAt, - nameIsUnique = nameIsUnique, - ) - } - - private fun findRosterConfirmedExactMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - boundary: MembershipBoundary, - from: Instant?, - observedAt: Instant, - ): Long? { - val boundaryColumn = if (boundary == MembershipBoundary.JOIN) "joined_at" else "left_at" - val sourceColumn = if (boundary == MembershipBoundary.JOIN) { - "joined_source" - } else { - "left_source" - } - val precisionColumn = if (boundary == MembershipBoundary.JOIN) { - "joined_precision" - } else { - "left_precision" - } - val compatibility = if (boundary == MembershipBoundary.JOIN) { - "(left_at IS NULL OR left_at >= ?)" - } else { - "(joined_at IS NULL OR joined_at <= ?)" - } - val fromClause = if (from == null) "" else "AND $boundaryColumn > ?" - val arguments = buildList { - add(uid.toString()) - add(EvidenceSource.GAME_UPDATES.name) - add(EvidencePrecision.EXACT.name) - add(observedAt.toEpochMilli().toString()) - if (from != null) add(from.toEpochMilli().toString()) - add(observedAt.toEpochMilli().toString()) - }.toTypedArray() - val candidates = db.rawQuery( - """ - SELECT id, $boundaryColumn - FROM membership_periods - WHERE uid = ? - AND $sourceColumn = ? - AND $precisionColumn = ? - AND $boundaryColumn <= ? - $fromClause - AND $compatibility - ORDER BY $boundaryColumn DESC, id DESC - """.trimIndent(), - arguments, - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add(cursor.getLong(0) to Instant.ofEpochMilli(cursor.getLong(1))) - } - } - } - return candidates.firstOrNull { (_, boundaryAt) -> - !hasLaterOppositeBoundaryThrough( - db = db, - uid = uid, - boundary = boundary, - boundaryAt = boundaryAt, - observedAt = observedAt, - ) - }?.first - } - - private fun applyExactUpdateBoundary( - db: SQLiteDatabase, - member: PlatoonUpdateMemberObservation, - type: MemberEventType, - boundary: MembershipBoundary, - occurredAt: Instant, - observedAt: Instant, - ): Boolean { - val boundaryTypes = when (boundary) { - MembershipBoundary.JOIN -> listOf(MemberEventType.JOINED, MemberEventType.REJOINED) - MembershipBoundary.WITHDRAW -> listOf(MemberEventType.LEFT, MemberEventType.REMOVED) - } - val exactExists = db.rawQuery( - """ - SELECT 1 - FROM member_events - WHERE uid = ? - AND source = ? - AND occurred_at = ? - AND event_type IN (?, ?) - LIMIT 1 - """.trimIndent(), - arrayOf( - member.uid.toString(), - EvidenceSource.GAME_UPDATES.name, - occurredAt.toEpochMilli().toString(), - boundaryTypes[0].name, - boundaryTypes[1].name, - ), - ).use(Cursor::moveToFirst) - if (exactExists) return false - - val candidates = db.rawQuery( - """ - SELECT id, membership_period_id, COALESCE(occurred_at, observed_at), - ABS(COALESCE(occurred_at, observed_at) - ?) AS distance - FROM member_events - WHERE uid = ? - AND event_type IN (?, ?) - AND source != ? - AND ABS(COALESCE(occurred_at, observed_at) - ?) <= ? - ORDER BY distance, - CASE source - WHEN '${EvidenceSource.GAME_UPDATES.name}' THEN 0 - WHEN '${EvidenceSource.MANUAL.name}' THEN 1 - ELSE 2 - END, - id DESC - """.trimIndent(), - arrayOf( - occurredAt.toEpochMilli().toString(), - member.uid.toString(), - boundaryTypes[0].name, - boundaryTypes[1].name, - EvidenceSource.GAME_UPDATES.name, - occurredAt.toEpochMilli().toString(), - EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - BoundaryEventCandidate( - id = cursor.getLong(0), - membershipPeriodId = cursor.getNullableLong(1), - boundaryAt = Instant.ofEpochMilli(cursor.getLong(2)), - ), - ) - } - } - } - val correlatableCandidates = candidates.filter { candidate -> - !hasOppositeBoundaryBetween( - db = db, - uid = member.uid, - boundary = boundary, - first = candidate.boundaryAt, - second = occurredAt, - ) - } - val membershipPeriodId = correlatableCandidates - .firstNotNullOfOrNull(BoundaryEventCandidate::membershipPeriodId) - ?: findOrCreateUpdateMembershipPeriod(db, member.uid, boundary, occurredAt) - - correlatableCandidates.forEach { candidate -> - db.delete("member_events", "id = ?", arrayOf(candidate.id.toString())) - } - correlatableCandidates.mapNotNull(BoundaryEventCandidate::membershipPeriodId) - .filter { it != membershipPeriodId } - .distinct() - .forEach { candidateMembershipPeriodId -> - mergeShadowInferredMembershipPeriod( - db = db, - selectedMembershipPeriodId = membershipPeriodId, - shadowMembershipPeriodId = candidateMembershipPeriodId, - boundary = boundary, - occurredAt = occurredAt, - ) - } - - applyExactMembershipBoundary( - db = db, - membershipPeriodId = membershipPeriodId, - uid = member.uid, - memberName = member.name, - type = if (boundary == MembershipBoundary.JOIN && - hasEarlierMembershipPeriod(db, member.uid, membershipPeriodId) - ) { - MemberEventType.REJOINED - } else { - type - }, - boundary = boundary, - occurredAt = occurredAt, - observedAt = observedAt, - activityIds = emptyList(), - ) - if (boundary == MembershipBoundary.WITHDRAW && - MembershipChronology.shouldRestoreRosterActiveMembershipPeriod( - memberIsActive = isActiveMember(db, member.uid), - withdrawalPredatesRoster = hasRosterPresenceAfter(db, member.uid, occurredAt), - ) - ) { - /* - * The historical boundary and a later authoritative roster are - * both true. Close the compatible historical membershipPeriod above, then - * preserve exactly one unknown current membershipPeriod for the later roster. - * A subsequent exact rejoin in the replay will refine this membershipPeriod. - */ - ensureRosterActiveMembershipPeriod(db, member.uid, EvidenceSource.SNAPSHOT) - } - return true - } - - private fun hasOppositeBoundaryBetween( - db: SQLiteDatabase, - uid: Long, - boundary: MembershipBoundary, - first: Instant, - second: Instant, - ): Boolean { - if (first == second) return false - val oppositeTypes = when (boundary) { - MembershipBoundary.JOIN -> listOf(MemberEventType.LEFT, MemberEventType.REMOVED) - MembershipBoundary.WITHDRAW -> listOf( - MemberEventType.JOINED, - MemberEventType.REJOINED, - ) - } - val start = minOf(first, second).toEpochMilli() - val end = maxOf(first, second).toEpochMilli() - return db.rawQuery( - """ - SELECT 1 - FROM member_events - WHERE uid = ? - AND event_type IN (?, ?) - AND COALESCE(occurred_at, observed_at) > ? - AND COALESCE(occurred_at, observed_at) < ? - LIMIT 1 - """.trimIndent(), - arrayOf( - uid.toString(), - oppositeTypes[0].name, - oppositeTypes[1].name, - start.toString(), - end.toString(), - ), - ).use(Cursor::moveToFirst) - } - - private fun hasLaterOppositeBoundaryThrough( - db: SQLiteDatabase, - uid: Long, - boundary: MembershipBoundary, - boundaryAt: Instant, - observedAt: Instant, - ): Boolean { - if (!observedAt.isAfter(boundaryAt)) return false - val oppositeTypes = when (boundary) { - MembershipBoundary.JOIN -> listOf(MemberEventType.LEFT, MemberEventType.REMOVED) - MembershipBoundary.WITHDRAW -> listOf( - MemberEventType.JOINED, - MemberEventType.REJOINED, - ) - } - val oppositeBoundaryAt = db.rawQuery( - """ - SELECT MIN(COALESCE(occurred_at, observed_at)) - FROM member_events - WHERE uid = ? - AND event_type IN (?, ?) - AND COALESCE(occurred_at, observed_at) > ? - AND COALESCE(occurred_at, observed_at) <= ? - """.trimIndent(), - arrayOf( - uid.toString(), - oppositeTypes[0].name, - oppositeTypes[1].name, - boundaryAt.toEpochMilli().toString(), - observedAt.toEpochMilli().toString(), - ), - ).use { cursor -> - if (cursor.moveToFirst()) cursor.getNullableLong(0)?.let(Instant::ofEpochMilli) else null - } - return MembershipChronology.hasSupersedingOppositeBoundary( - boundaryAt = boundaryAt, - observedAt = observedAt, - oppositeBoundaryTimes = listOfNotNull(oppositeBoundaryAt), - ) - } - - private fun findOrCreateUpdateMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - boundary: MembershipBoundary, - occurredAt: Instant, - ): Long { - val occurredAtMillis = occurredAt.toEpochMilli() - val nearby = when (boundary) { - MembershipBoundary.JOIN -> db.rawQuery( - """ - SELECT id - FROM membership_periods - WHERE uid = ? - AND (left_at IS NULL OR left_at >= ?) - AND ( - joined_at IS NULL - OR ( - joined_precision != ? - AND ABS(joined_at - ?) <= ? - ) - ) - ORDER BY - CASE WHEN left_at IS NULL THEN 0 ELSE 1 END, - CASE WHEN joined_at IS NULL THEN 0 ELSE 1 END, - ABS(COALESCE(joined_at, ?) - ?), - id DESC - LIMIT 1 - """.trimIndent(), - arrayOf( - uid.toString(), - occurredAtMillis.toString(), - EvidencePrecision.EXACT.name, - occurredAtMillis.toString(), - EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), - occurredAtMillis.toString(), - occurredAtMillis.toString(), - ), - ) - MembershipBoundary.WITHDRAW -> db.rawQuery( - """ - SELECT id - FROM membership_periods - WHERE uid = ? - AND (joined_at IS NULL OR joined_at <= ?) - AND ( - left_at IS NULL - OR ( - left_precision != ? - AND ABS(left_at - ?) <= ? - ) - ) - ORDER BY - CASE WHEN left_at IS NULL THEN 0 ELSE 1 END, - CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, - joined_at DESC, - ABS(COALESCE(left_at, ?) - ?), - id DESC - LIMIT 1 - """.trimIndent(), - arrayOf( - uid.toString(), - occurredAtMillis.toString(), - EvidencePrecision.EXACT.name, - occurredAtMillis.toString(), - EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), - occurredAtMillis.toString(), - occurredAtMillis.toString(), - ), - ) - }.use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } - if (nearby != null) return nearby - return insertMembershipPeriod( - db = db, - uid = uid, - joinedAt = if (boundary == MembershipBoundary.JOIN) occurredAt else null, - precision = if (boundary == MembershipBoundary.JOIN) { - EvidencePrecision.EXACT - } else { - EvidencePrecision.UNKNOWN - }, - source = EvidenceSource.GAME_UPDATES, - ) - } - - private fun isActiveMember(db: SQLiteDatabase, uid: Long): Boolean = - db.rawQuery( - "SELECT is_active FROM members WHERE uid = ? LIMIT 1", - arrayOf(uid.toString()), - ).use { cursor -> cursor.moveToFirst() && cursor.getInt(0) != 0 } - - private fun hasRosterPresenceAfter( - db: SQLiteDatabase, - uid: Long, - occurredAt: Instant, - ): Boolean { - val latestRosterPresenceAt = db.rawQuery( - """ - SELECT MAX(snapshot.captured_at) - FROM snapshots snapshot - JOIN snapshot_members member ON member.snapshot_id = snapshot.id - WHERE member.uid = ? - """.trimIndent(), - arrayOf(uid.toString()), - ).use { cursor -> - if (cursor.moveToFirst()) cursor.getNullableLong(0)?.let(Instant::ofEpochMilli) else null - } - return MembershipChronology.withdrawalPredatesRosterPresence( - withdrewAt = occurredAt, - latestRosterPresenceAt = latestRosterPresenceAt, - ) - } - - private fun mergeShadowInferredMembershipPeriod( - db: SQLiteDatabase, - selectedMembershipPeriodId: Long, - shadowMembershipPeriodId: Long, - boundary: MembershipBoundary, - occurredAt: Instant, - ) { - val boundaryColumn = if (boundary == MembershipBoundary.JOIN) "joined_at" else "left_at" - val precisionColumn = if (boundary == MembershipBoundary.JOIN) { - "joined_precision" - } else { - "left_precision" - } - val shadow = db.rawQuery( - """ - SELECT joined_at, left_at, - joined_date, left_date, - joined_time_known, left_time_known, - joined_precision, left_precision, - joined_source, left_source - FROM membership_periods - WHERE id = ? - AND $precisionColumn IN (?, ?) - AND ( - $boundaryColumn IS NULL - OR ABS($boundaryColumn - ?) <= ? - ) - LIMIT 1 - """.trimIndent(), - arrayOf( - shadowMembershipPeriodId.toString(), - EvidencePrecision.INFERRED.name, - EvidencePrecision.UNKNOWN.name, - occurredAt.toEpochMilli().toString(), - EXACT_UPDATE_CORRELATION_WINDOW_MILLIS.toString(), - ), - ).use { cursor -> - if (!cursor.moveToFirst()) { - null - } else { - ShadowMembershipPeriod( - joinedAt = cursor.getNullableLong(0), - leftAt = cursor.getNullableLong(1), - joinedDate = cursor.getNullableLong(2), - leftDate = cursor.getNullableLong(3), - joinedTimeKnown = cursor.getNullableInt(4), - leftTimeKnown = cursor.getNullableInt(5), - joinedPrecision = cursor.getNullableString(6), - leftPrecision = cursor.getNullableString(7), - joinedSource = cursor.getNullableString(8), - leftSource = cursor.getNullableString(9), - ) - } - } ?: return - - val oppositePrefix = if (boundary == MembershipBoundary.JOIN) "left" else "joined" - val oppositeAt = if (boundary == MembershipBoundary.JOIN) shadow.leftAt else shadow.joinedAt - if (oppositeAt != null) { - val selectedHasOpposite = db.rawQuery( - "SELECT ${oppositePrefix}_at FROM membership_periods WHERE id = ?", - arrayOf(selectedMembershipPeriodId.toString()), - ).use { cursor -> cursor.moveToFirst() && !cursor.isNull(0) } - if (selectedHasOpposite) return - - val values = ContentValues().apply { - put("${oppositePrefix}_at", oppositeAt) - if (boundary == MembershipBoundary.JOIN) { - putNullableLong("${oppositePrefix}_date", shadow.leftDate) - putNullableInt("${oppositePrefix}_time_known", shadow.leftTimeKnown) - put("${oppositePrefix}_precision", shadow.leftPrecision) - put("${oppositePrefix}_source", shadow.leftSource) - } else { - putNullableLong("${oppositePrefix}_date", shadow.joinedDate) - putNullableInt("${oppositePrefix}_time_known", shadow.joinedTimeKnown) - put("${oppositePrefix}_precision", shadow.joinedPrecision) - put("${oppositePrefix}_source", shadow.joinedSource) - } - } - db.update( - "membership_periods", - values, - "id = ?", - arrayOf(selectedMembershipPeriodId.toString()), - ) - val oppositeTypes = if (boundary == MembershipBoundary.JOIN) { - listOf(MemberEventType.LEFT, MemberEventType.REMOVED) - } else { - listOf(MemberEventType.JOINED, MemberEventType.REJOINED) - } - db.update( - "member_events", - ContentValues().apply { put("membership_period_id", selectedMembershipPeriodId) }, - "membership_period_id = ? AND event_type IN (?, ?)", - arrayOf( - shadowMembershipPeriodId.toString(), - oppositeTypes[0].name, - oppositeTypes[1].name, - ), - ) - } - db.delete("member_events", "membership_period_id = ?", arrayOf(shadowMembershipPeriodId.toString())) - db.delete("membership_periods", "id = ?", arrayOf(shadowMembershipPeriodId.toString())) - } + ): UpdatesIngestResult = ExactBoundaryCorrelation.ingest(writableDatabase, observations, capturedAt) @Synchronized - fun listDailyPatrolFacts(from: Instant, until: Instant): List { - require(!until.isBefore(from)) - return readableDatabase.query( - true, - "platoon_activity", - arrayOf("resolved_uid", "occurred_at"), - """ - action_id = ? AND resolved_uid IS NOT NULL - AND resolution = ? AND occurred_at >= ? AND occurred_at < ? - """.trimIndent(), - arrayOf( - DAILY_PATROL_REWARD_ACTION_ID.toString(), - ActivityResolution.EXACT_UPDATE.name, - from.toEpochMilli().toString(), - until.toEpochMilli().toString(), - ), - null, - null, - "occurred_at, resolved_uid", - null, - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - DailyPatrolFact( - uid = cursor.getLong(0), - occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), - ), - ) - } - } - } - } + fun listDailyPatrolFacts(from: Instant, until: Instant): List = + PlatoonReadQueries.listDailyPatrolFacts(readableDatabase, from, until) @Synchronized fun addWithdrawnMember( @@ -1895,37 +112,7 @@ internal class PlatoonDatabase private constructor( joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue, note: String, - ): Boolean { - require(uid > 0) - require(name.isNotBlank()) - require(isValidMembershipRange(joined, withdrew)) - val db = writableDatabase - db.beginTransaction() - try { - if (memberExists(db, uid)) return false - db.insertOrThrow( - "members", - null, - ContentValues().apply { - put("uid", uid) - put("current_name", name.trim()) - put("custom_name", name.trim()) - put("current_level", 0) - put("is_active", 0) - put("first_seen_at", joined.instant.toEpochMilli()) - put("last_seen_at", withdrew.instant.toEpochMilli()) - put("note", "") - }, - ) - val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) - replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, name.trim(), joined, withdrew) - synchronizeAndVerifyMembershipState(db, uid) - db.setTransactionSuccessful() - return true - } finally { - db.endTransaction() - } - } + ): Boolean = MembershipEditing.addWithdrawnMember(writableDatabase, uid, name, joined, withdrew, note) @Synchronized fun addMembershipPeriod( @@ -1933,22 +120,7 @@ internal class PlatoonDatabase private constructor( joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue?, note: String, - ): Boolean { - require(isValidMembershipRange(joined, withdrew)) - val db = writableDatabase - db.beginTransaction() - try { - val memberName = memberName(db, uid) ?: return false - if (!canStoreMembershipPeriod(db, uid, null, joined, withdrew)) return false - val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) - replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, memberName, joined, withdrew) - synchronizeAndVerifyMembershipState(db, uid) - db.setTransactionSuccessful() - return true - } finally { - db.endTransaction() - } - } + ): Boolean = MembershipEditing.addMembershipPeriod(writableDatabase, uid, joined, withdrew, note) @Synchronized fun listSnapshots(limit: Int = 100): List { @@ -1957,9 +129,8 @@ internal class PlatoonDatabase private constructor( } /** - * Loads only the snapshots capable of affecting a reporting period plus - * the nearest prior roster/baseline. This avoids an arbitrary historical - * cap and keeps weekly rendering independent of database growth. + * Loads only the snapshots capable of affecting a reporting period plus the nearest prior + * roster/baseline, independent of database growth. */ @Synchronized fun listSnapshotsForPeriod(from: Instant, until: Instant): List { @@ -1968,37 +139,8 @@ internal class PlatoonDatabase private constructor( } @Synchronized - fun listMemberStatuses(activeOnly: Boolean = false): List { - val db = readableDatabase - val selection = if (activeOnly) "is_active = 1" else null - return db.query( - "members", - MEMBER_COLUMNS, - selection, - null, - null, - null, - "is_active DESC, COALESCE(custom_name, current_name) COLLATE NOCASE, uid", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - val uid = cursor.getLong(0) - add( - MemberStatus( - uid = uid, - name = cursor.getString(1), - level = cursor.getLong(2), - isActive = cursor.getInt(3) != 0, - firstSeenAt = Instant.ofEpochMilli(cursor.getLong(4)), - lastSeenAt = Instant.ofEpochMilli(cursor.getLong(5)), - note = cursor.getString(6), - membershipPeriods = readMembershipPeriods(db, uid), - ), - ) - } - } - } - } + fun listMemberStatuses(activeOnly: Boolean = false): List = + PlatoonReadQueries.listMemberStatuses(readableDatabase, activeOnly) @Synchronized fun listEvents( @@ -2006,153 +148,18 @@ internal class PlatoonDatabase private constructor( until: Instant, fromDate: LocalDate, untilDate: LocalDate, - ): List { - require(!until.isBefore(from)) - require(!untilDate.isBefore(fromDate)) - return readableDatabase.query( - "member_events", - EVENT_COLUMNS, - """ - ( - event_date IS NOT NULL - AND event_date >= CAST(? AS INTEGER) - AND event_date < CAST(? AS INTEGER) - ) - OR - ( - event_date IS NULL - AND COALESCE(occurred_at, observed_at) >= CAST(? AS INTEGER) - AND COALESCE(occurred_at, observed_at) < CAST(? AS INTEGER) - ) - """.trimIndent(), - arrayOf( - fromDate.toEpochDay().toString(), - untilDate.toEpochDay().toString(), - from.toEpochMilli().toString(), - until.toEpochMilli().toString(), - ), - null, - null, - "COALESCE(occurred_at, observed_at), id", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) add(cursor.toMemberEvent()) - } - } - } + ): List = PlatoonReadQueries.listEvents(readableDatabase, from, until, fromDate, untilDate) @Synchronized - fun updateMember(uid: Long, name: String, note: String): Boolean { - require(name.isNotBlank()) - val values = ContentValues().apply { - put("custom_name", name.trim()) - put("note", note.trim()) - } - return writableDatabase.update("members", values, "uid = ?", arrayOf(uid.toString())) == 1 - } + fun updateMember(uid: Long, name: String, note: String): Boolean = + MembershipEditing.updateMember(writableDatabase, uid, name, note) @Synchronized - fun deleteMember(uid: Long): Boolean { - require(uid > 0) - val db = writableDatabase - db.beginTransaction() - try { - if (!memberExists(db, uid)) return false - val uidArgument = arrayOf(uid.toString()) - db.delete("weekly_overrides", "uid = ?", uidArgument) - db.delete( - "platoon_activity", - "resolved_uid = ? OR member_event_id IN " + - "(SELECT id FROM member_events WHERE uid = ?)", - arrayOf(uid.toString(), uid.toString()), - ) - db.delete( - "weekly_notes", - "is_automatic = 1 AND event_id IN " + - "(SELECT id FROM member_events WHERE uid = ?)", - uidArgument, - ) - db.delete("member_events", "uid = ?", uidArgument) - db.delete("membership_periods", "uid = ?", uidArgument) - db.delete("snapshot_members", "uid = ?", uidArgument) - val deleted = db.delete("members", "uid = ?", uidArgument) == 1 - check(deleted) { "Member disappeared during deletion" } - db.setTransactionSuccessful() - return true - } finally { - db.endTransaction() - } - } + fun deleteMember(uid: Long): Boolean = MembershipEditing.deleteMember(writableDatabase, uid) - // Function Name: deleteMembershipPeriod - // Description: - // - Deletes one membership period and its derived boundary events atomically. - // - Refuses to remove the member's final period and recomputes current active state. - // Parameters: - // - membershipPeriodId: Persistent period identifier selected in member details. - // Returns: - // - True when the period was deleted; false when it was missing or was the only period. @Synchronized - fun deleteMembershipPeriod(membershipPeriodId: Long): Boolean { - require(membershipPeriodId > 0) - val db = writableDatabase - db.beginTransaction() - try { - val uid = db.rawQuery( - "SELECT uid FROM membership_periods WHERE id = ?", - arrayOf(membershipPeriodId.toString()), - ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } - ?: return false - val periodCount = db.rawQuery( - "SELECT COUNT(*) FROM membership_periods WHERE uid = ?", - arrayOf(uid.toString()), - ).use { cursor -> - check(cursor.moveToFirst()) - cursor.getLong(0) - } - if (periodCount <= 1L) return false - - db.delete( - "weekly_notes", - "is_automatic = 1 AND event_id IN " + - "(SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.update( - "weekly_notes", - ContentValues().apply { putNull("event_id") }, - "event_id IN " + - "(SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.update( - "platoon_activity", - ContentValues().apply { putNull("member_event_id") }, - "member_event_id IN " + - "(SELECT id FROM member_events WHERE membership_period_id = ?)", - arrayOf(membershipPeriodId.toString()), - ) - db.delete( - "member_events", - "membership_period_id = ?", - arrayOf(membershipPeriodId.toString()), - ) - if ( - db.delete( - "membership_periods", - "id = ? AND uid = ?", - arrayOf(membershipPeriodId.toString(), uid.toString()), - ) != 1 - ) { - return false - } - synchronizeAndVerifyMembershipState(db, uid) - db.setTransactionSuccessful() - return true - } finally { - db.endTransaction() - } - } + fun deleteMembershipPeriod(membershipPeriodId: Long): Boolean = + MembershipEditing.deleteMembershipPeriod(writableDatabase, membershipPeriodId) @Synchronized fun updateMembershipPeriod( @@ -2160,384 +167,44 @@ internal class PlatoonDatabase private constructor( joined: MembershipBoundaryValue, left: MembershipBoundaryValue?, note: String, - ): Boolean { - val db = writableDatabase - db.beginTransaction() - try { - val membershipPeriod = readMembershipPeriodForUpdate(db, membershipPeriodId) ?: return false - val effectiveJoined = if (membershipPeriod.joinedSource.isImmutableMembershipBoundary()) { - membershipPeriod.joined - } else { - joined - } ?: return false - val effectiveLeft = if (membershipPeriod.leftSource?.isImmutableMembershipBoundary() == true) { - membershipPeriod.left - } else { - left - } - require(isValidMembershipRange(effectiveJoined, effectiveLeft)) - if ( - !canStoreMembershipPeriod( - db, - membershipPeriod.uid, - membershipPeriodId, - effectiveJoined, - effectiveLeft, - ) - ) { - return false - } - val joinedChanged = editableMembershipBoundaryChanged( - original = membershipPeriod.joined, - requested = joined, - source = membershipPeriod.joinedSource, - ) - val leftChanged = editableMembershipBoundaryChanged( - original = membershipPeriod.left, - requested = left, - source = membershipPeriod.leftSource, - ) - val resultingJoinedSource = if (joinedChanged) { - EvidenceSource.MANUAL - } else { - membershipPeriod.joinedSource - } - val resultingLeftSource = if (leftChanged) { - left?.let { EvidenceSource.MANUAL } - } else { - membershipPeriod.leftSource - } - val updated = db.update( - "membership_periods", - ContentValues().apply { - if (joinedChanged) { - putMembershipBoundary("joined", joined) - put("joined_precision", EvidencePrecision.MANUAL.name) - put("joined_source", EvidenceSource.MANUAL.name) - } - if (leftChanged) { - if (left == null) { - clearMembershipBoundary("left") - putNull("left_precision") - putNull("left_source") - } else { - putMembershipBoundary("left", left) - put("left_precision", EvidencePrecision.MANUAL.name) - put("left_source", EvidenceSource.MANUAL.name) - } - } - put("note", note.trim()) - }, - "id = ?", - arrayOf(membershipPeriodId.toString()), - ) == 1 - if (updated && (joinedChanged || leftChanged)) { - replaceManualMembershipPeriodEvents( - db, - membershipPeriodId, - membershipPeriod.uid, - memberName(db, membershipPeriod.uid).orEmpty(), - effectiveJoined.takeIf { - resultingJoinedSource == EvidenceSource.MANUAL - }, - effectiveLeft.takeIf { - resultingLeftSource == EvidenceSource.MANUAL - }, - ) - } - if (updated) synchronizeAndVerifyMembershipState(db, membershipPeriod.uid) - db.setTransactionSuccessful() - return updated - } finally { - db.endTransaction() - } - } - - // Function Name: canStoreMembershipPeriod - // Description: - // - Evaluates a proposed manual period together with every persisted period for the member. - // - Rejects overlapping ranges and multiple open periods before any database row is changed. - private fun canStoreMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - replacedPeriodId: Long?, - joined: MembershipBoundaryValue, - left: MembershipBoundaryValue?, - ): Boolean { - val proposed = MembershipInterval( - id = replacedPeriodId ?: Long.MAX_VALUE, - joinedAt = joined.instant, - leftAt = left?.instant, - joinedDate = joined.date, - leftDate = left?.date, - joinedTimeKnown = joined.timeKnown, - leftTimeKnown = left?.timeKnown ?: true, - ) - val periods = readMembershipIntervals(db, uid) - .filterNot { it.id == replacedPeriodId } + proposed - return MembershipConsistencyPolicy.violation(periods) == null - } - - // Function Name: synchronizeAndVerifyMembershipState - // Description: - // - Derives current member activity from the canonical period timeline. - // - Verifies chronology and event ownership before allowing the surrounding transaction to commit. - private fun synchronizeAndVerifyMembershipState(db: SQLiteDatabase, uid: Long) { - val periods = readMembershipIntervals(db, uid) - checkNotNull(periods.takeIf { it.isNotEmpty() }) { "A member must retain a membership period" } - val violation = MembershipConsistencyPolicy.violation(periods) - check(violation == null) { violation.orEmpty() } - val updated = db.update( - "members", - ContentValues().apply { - put("is_active", if (MembershipConsistencyPolicy.isActive(periods)) 1 else 0) - }, - "uid = ?", - arrayOf(uid.toString()), - ) - check(updated == 1) { "Membership state has no owning member" } - val mismatchedEvent = db.rawQuery( - "SELECT 1 FROM member_events event " + - "JOIN membership_periods period ON period.id = event.membership_period_id " + - "WHERE event.uid != period.uid AND (event.uid = ? OR period.uid = ?) LIMIT 1", - arrayOf(uid.toString(), uid.toString()), - ).use(Cursor::moveToFirst) - check(!mismatchedEvent) { "Membership event belongs to a different member" } - } - - // Function Name: synchronizeAllMembershipStates - // Description: - // - Repairs the derived active flag while upgrading databases created before schema v13. - // - Leaves every evidence period intact and derives only the current-state projection. - private fun synchronizeAllMembershipStates(db: SQLiteDatabase) { - db.execSQL( - """ - UPDATE members - SET is_active = CASE WHEN EXISTS ( - SELECT 1 - FROM membership_periods period - WHERE period.uid = members.uid AND period.left_at IS NULL - ) THEN 1 ELSE 0 END - """.trimIndent(), - ) - } - - private fun readMembershipIntervals(db: SQLiteDatabase, uid: Long): List = - db.rawQuery( - "SELECT id, joined_at, left_at, joined_date, left_date, " + - "joined_time_known, left_time_known " + - "FROM membership_periods WHERE uid = ? ORDER BY id", - arrayOf(uid.toString()), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - MembershipInterval( - id = cursor.getLong(0), - joinedAt = cursor.getNullableLong(1)?.let(Instant::ofEpochMilli), - leftAt = cursor.getNullableLong(2)?.let(Instant::ofEpochMilli), - joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), - leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), - joinedTimeKnown = cursor.getNullableInt(5)?.let { it != 0 } ?: true, - leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 } ?: true, - ), - ) - } - } - } + ): Boolean = MembershipEditing.updateMembershipPeriod( + writableDatabase, + ::gameZone, + membershipPeriodId, + joined, + left, + note, + ) @Synchronized - fun addWeeklyNote(periodStartEpochDay: Long, gameDayEpochDay: Long, text: String): Long { - require(text.isNotBlank()) - val manualNoteCount = readableDatabase.rawQuery( - "SELECT COUNT(*) FROM weekly_notes WHERE period_start = ? AND is_automatic = 0", - arrayOf(periodStartEpochDay.toString()), - ).use { cursor -> - check(cursor.moveToFirst()) - cursor.getInt(0) - } - if (!WeeklyNotePolicy.canAdd(manualNoteCount)) throw WeeklyNoteLimitException() - return writableDatabase.insertOrThrow( - "weekly_notes", - null, - ContentValues().apply { - put("period_start", periodStartEpochDay) - put("game_day", gameDayEpochDay) - put("text", text.trim()) - putNull("event_id") - put("is_automatic", 0) - }, - ) - } + fun addWeeklyNote(periodStartEpochDay: Long, gameDayEpochDay: Long, text: String): Long = + WeeklyAnnotationStore.addWeeklyNote(writableDatabase, periodStartEpochDay, gameDayEpochDay, text) @Synchronized fun listWeeklyNotes(periodStartEpochDay: Long): List = - readableDatabase.query( - "weekly_notes", - WEEKLY_NOTE_COLUMNS, - "period_start = ?", - arrayOf(periodStartEpochDay.toString()), - null, - null, - "game_day, id", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - WeeklyNote( - id = cursor.getLong(0), - periodStart = java.time.LocalDate.ofEpochDay(cursor.getLong(1)), - gameDay = java.time.LocalDate.ofEpochDay(cursor.getLong(2)), - text = cursor.getString(3), - eventId = cursor.getNullableLong(4), - isAutomatic = cursor.getInt(5) != 0, - ), - ) - } - } - } + WeeklyAnnotationStore.listWeeklyNotes(readableDatabase, periodStartEpochDay) @Synchronized - fun weeklyNotePeriodStart(id: Long): LocalDate? = readableDatabase.rawQuery( - "SELECT period_start FROM weekly_notes WHERE id = ? AND is_automatic = 0", - arrayOf(id.toString()), - ).use { cursor -> - if (cursor.moveToFirst()) LocalDate.ofEpochDay(cursor.getLong(0)) else null - } + fun weeklyNotePeriodStart(id: Long): LocalDate? = + WeeklyAnnotationStore.weeklyNotePeriodStart(readableDatabase, id) @Synchronized - fun deleteWeeklyNote(id: Long): Boolean = - writableDatabase.delete( - "weekly_notes", - "id = ? AND is_automatic = 0", - arrayOf(id.toString()), - ) == 1 + fun deleteWeeklyNote(id: Long): Boolean = WeeklyAnnotationStore.deleteWeeklyNote(writableDatabase, id) @Synchronized fun listWeeklyOverrides(periodStartEpochDay: Long): List = - readableDatabase.query( - "weekly_overrides", - WEEKLY_OVERRIDE_COLUMNS, - "period_start = ?", - arrayOf(periodStartEpochDay.toString()), - null, - null, - "game_day, uid", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - WeeklyCellOverride( - uid = cursor.getLong(0), - periodStart = java.time.LocalDate.ofEpochDay(cursor.getLong(1)), - gameDay = java.time.LocalDate.ofEpochDay(cursor.getLong(2)), - meritDelta = cursor.getNullableLong(3), - scoreDelta = cursor.getNullableLong(4), - attempts = cursor.getNullableInt(5), - attended = cursor.getNullableBoolean(6), - dailyPatrol = cursor.getNullableBoolean(7), - ), - ) - } - } - } + WeeklyAnnotationStore.listWeeklyOverrides(readableDatabase, periodStartEpochDay) @Synchronized - fun listWeeklyEvidenceDays(zoneId: ZoneId): List { - val db = readableDatabase - val days = mutableListOf() - listOf( - "snapshots" to "captured_at", - "platoon_activity" to "occurred_at", - ).forEach { (table, expression) -> - db.rawQuery("SELECT DISTINCT $expression FROM $table", null).use { cursor -> - while (cursor.moveToNext()) { - if (!cursor.isNull(0)) { - days += PlatoonPeriods.gameDay(Instant.ofEpochMilli(cursor.getLong(0)), zoneId) - } - } - } - } - db.rawQuery( - "SELECT DISTINCT COALESCE(occurred_at, observed_at) " + - "FROM member_events WHERE event_date IS NULL", - null, - ).use { cursor -> - while (cursor.moveToNext()) { - if (!cursor.isNull(0)) { - days += PlatoonPeriods.gameDay(Instant.ofEpochMilli(cursor.getLong(0)), zoneId) - } - } - } - listOf("weekly_notes", "weekly_overrides").forEach { table -> - db.rawQuery("SELECT DISTINCT period_start FROM $table", null).use { cursor -> - while (cursor.moveToNext()) { - if (!cursor.isNull(0)) days += LocalDate.ofEpochDay(cursor.getLong(0)) - } - } - } - db.rawQuery("SELECT DISTINCT event_date FROM member_events", null).use { cursor -> - while (cursor.moveToNext()) { - if (!cursor.isNull(0)) days += LocalDate.ofEpochDay(cursor.getLong(0)) - } - } - return days.distinct() - } + fun listWeeklyEvidenceDays(zoneId: ZoneId): List = + PlatoonReadQueries.listWeeklyEvidenceDays(readableDatabase, zoneId) @Synchronized fun replaceWeeklyOverrides( periodStartEpochDay: Long, overrides: List, - ) { - require(overrides.all { it.periodStart.toEpochDay() == periodStartEpochDay }) - require( - overrides.all { - it.attempts == null || it.attempts in 0..ActivityInference.MAX_DAILY_ATTEMPTS - }, - ) - require(overrides.none { it.attended == false && it.dailyPatrol == true }) - val db = writableDatabase - db.beginTransaction() - try { - db.delete( - "weekly_overrides", - "period_start = ?", - arrayOf(periodStartEpochDay.toString()), - ) - overrides.forEach { override -> - db.insertOrThrow( - "weekly_overrides", - null, - ContentValues().apply { - put("uid", override.uid) - put("period_start", periodStartEpochDay) - put("game_day", override.gameDay.toEpochDay()) - putNullableLong("merit_delta", override.meritDelta) - putNullableLong("score_delta", override.scoreDelta) - putNullableInt("attempts", override.attempts) - putNullableBoolean("attended", override.attended) - putNullableBoolean("daily_patrol", override.dailyPatrol) - }, - ) - } - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } + ) = WeeklyAnnotationStore.replaceWeeklyOverrides(writableDatabase, periodStartEpochDay, overrides) - // Function Name: recordWeeklyReportHistory - // Description: - // - Appends one changed immutable weekly projection and retains at most fifteen revisions. - // - Clears an older restored projection only when new evidence changes the live table. - // Parameters: - // - periodStartEpochDay: Sunday key of the projected week. - // - recordedAt: Time at which the changed projection became visible. - // - fingerprint: SHA-256 identity of the bounded serialized projection. - // - payload: Versioned serialized weekly projection. - // - clearActiveOnChange: Whether a changed live projection should leave restore mode. - // Returns: - // - ID of the existing or newly recorded revision. @Synchronized fun recordWeeklyReportHistory( periodStartEpochDay: Long, @@ -2545,1481 +212,41 @@ internal class PlatoonDatabase private constructor( fingerprint: String, payload: ByteArray, clearActiveOnChange: Boolean, - ): Long { - require(fingerprint.matches(Regex("[0-9a-f]{64}"))) - require(payload.size in 1..WeeklyReportHistoryCodec.MAX_PAYLOAD_BYTES) - val db = writableDatabase - db.beginTransaction() - try { - val latest = db.rawQuery( - "SELECT id, fingerprint FROM weekly_report_history " + - "WHERE period_start = ? ORDER BY recorded_at DESC, id DESC LIMIT 1", - arrayOf(periodStartEpochDay.toString()), - ).use { cursor -> - if (cursor.moveToFirst()) cursor.getLong(0) to cursor.getString(1) else null - } - if (latest?.second == fingerprint) { - db.setTransactionSuccessful() - return latest.first - } - val id = db.insertOrThrow( - "weekly_report_history", - null, - ContentValues().apply { - put("period_start", periodStartEpochDay) - put("recorded_at", recordedAt.toEpochMilli()) - put("fingerprint", fingerprint) - put("report_blob", payload) - }, - ) - if (clearActiveOnChange) { - db.delete( - "weekly_report_history_state", - "period_start = ?", - arrayOf(periodStartEpochDay.toString()), - ) - } - db.delete( - "weekly_report_history", - "period_start = ? AND id NOT IN (" + - "SELECT id FROM weekly_report_history WHERE period_start = ? " + - "ORDER BY recorded_at DESC, id DESC LIMIT ?)", - arrayOf( - periodStartEpochDay.toString(), - periodStartEpochDay.toString(), - MAX_WEEKLY_REPORT_HISTORY.toString(), - ), - ) - db.setTransactionSuccessful() - return id - } finally { - db.endTransaction() - } - } + ): Long = WeeklyHistoryStore.record( + writableDatabase, + periodStartEpochDay, + recordedAt, + fingerprint, + payload, + clearActiveOnChange, + ) @Synchronized fun listWeeklyReportHistory(periodStartEpochDay: Long): List = - readableDatabase.rawQuery( - "SELECT history.id, history.recorded_at, " + - "CASE WHEN state.active_history_id = history.id THEN 1 ELSE 0 END " + - "FROM weekly_report_history history " + - "LEFT JOIN weekly_report_history_state state " + - "ON state.period_start = history.period_start " + - "WHERE history.period_start = ? " + - "ORDER BY history.recorded_at DESC, history.id DESC LIMIT ?", - arrayOf(periodStartEpochDay.toString(), MAX_WEEKLY_REPORT_HISTORY.toString()), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - WeeklyReportHistoryEntry( - id = cursor.getLong(0), - periodStart = LocalDate.ofEpochDay(periodStartEpochDay), - recordedAt = Instant.ofEpochMilli(cursor.getLong(1)), - active = cursor.getInt(2) != 0, - ), - ) - } - } - } + WeeklyHistoryStore.list(readableDatabase, periodStartEpochDay) @Synchronized fun weeklyReportHistoryPayload(id: Long, periodStartEpochDay: Long): ByteArray? = - readableDatabase.rawQuery( - "SELECT report_blob FROM weekly_report_history WHERE id = ? AND period_start = ?", - arrayOf(id.toString(), periodStartEpochDay.toString()), - ).use { cursor -> if (cursor.moveToFirst()) cursor.getBlob(0) else null } + WeeklyHistoryStore.payload(readableDatabase, id, periodStartEpochDay) @Synchronized fun activeWeeklyReportHistoryPayload(periodStartEpochDay: Long): ByteArray? = - readableDatabase.rawQuery( - "SELECT history.report_blob 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 } + WeeklyHistoryStore.activePayload(readableDatabase, periodStartEpochDay) @Synchronized - fun activateWeeklyReportHistory(id: Long, periodStartEpochDay: Long): Boolean { - val db = writableDatabase - val exists = db.rawQuery( - "SELECT 1 FROM weekly_report_history WHERE id = ? AND period_start = ?", - arrayOf(id.toString(), periodStartEpochDay.toString()), - ).use(Cursor::moveToFirst) - if (!exists) return false - db.insertWithOnConflict( - "weekly_report_history_state", - null, - ContentValues().apply { - put("period_start", periodStartEpochDay) - put("active_history_id", id) - }, - SQLiteDatabase.CONFLICT_REPLACE, - ) - return true - } + fun activateWeeklyReportHistory(id: Long, periodStartEpochDay: Long): Boolean = + WeeklyHistoryStore.activate(writableDatabase, id, periodStartEpochDay) @Synchronized fun clearActiveWeeklyReportHistory(periodStartEpochDay: Long): Boolean = - writableDatabase.delete( - "weekly_report_history_state", - "period_start = ?", - arrayOf(periodStartEpochDay.toString()), - ) > 0 + WeeklyHistoryStore.clearActive(writableDatabase, periodStartEpochDay) @Synchronized - fun clearWeeklyReportHistory() { - val db = writableDatabase - db.beginTransaction() - try { - db.delete("weekly_report_history_state", null, null) - db.delete("weekly_report_history", null, null) - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } + fun clearWeeklyReportHistory() = WeeklyHistoryStore.clear(writableDatabase) - // Function Name: replaceWeeklyReportHistory - // Description: - // - Replaces every derived weekly revision in one SQLite transaction. - // - Validates the complete replacement before deleting existing history. - // Parameters: - // - replacements: One prepared current revision for each evidence-backed week. - // Returns: - // - Unit after the complete replacement commits, or after SQLite rolls back on failure. @Synchronized - internal fun replaceWeeklyReportHistory(replacements: List) { - require(replacements.map { it.periodStartEpochDay }.distinct().size == replacements.size) - replacements.forEach { replacement -> - require(replacement.fingerprint.matches(Regex("[0-9a-f]{64}"))) - require(replacement.payload.size in 1..WeeklyReportHistoryCodec.MAX_PAYLOAD_BYTES) - } - val db = writableDatabase - db.beginTransaction() - try { - db.delete("weekly_report_history_state", null, null) - db.delete("weekly_report_history", null, null) - replacements.forEach { replacement -> - db.insertOrThrow( - "weekly_report_history", - null, - ContentValues().apply { - put("period_start", replacement.periodStartEpochDay) - put("recorded_at", replacement.recordedAt.toEpochMilli()) - put("fingerprint", replacement.fingerprint) - put("report_blob", replacement.payload) - }, - ) - } - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } - - private fun createWeeklyOverridesTable(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS 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) - ) - """.trimIndent(), - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS weekly_overrides_period " + - "ON weekly_overrides(period_start, game_day)", - ) - } - - private fun createPlatoonActivityTable(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS platoon_activity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - occurred_at INTEGER NOT NULL, - action_id INTEGER NOT NULL, - kind INTEGER NOT NULL, - member_name TEXT NOT NULL, - captured_at INTEGER NOT NULL, - resolved_uid INTEGER REFERENCES members(uid), - resolution TEXT NOT NULL DEFAULT 'UNRESOLVED', - member_event_id INTEGER REFERENCES member_events(id) - ) - """.trimIndent(), - ) - createPlatoonActivityIndexes(db) - } - - private fun createPlatoonActivityIndexes(db: SQLiteDatabase) { - db.execSQL( - "CREATE INDEX IF NOT EXISTS platoon_activity_member_time " + - "ON platoon_activity(member_name, occurred_at)", - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS platoon_activity_action_time " + - "ON platoon_activity(action_id, occurred_at)", - ) - db.execSQL( - """ - CREATE UNIQUE INDEX IF NOT EXISTS platoon_activity_exact_identity - ON platoon_activity(occurred_at, action_id, kind, resolved_uid) - WHERE resolved_uid IS NOT NULL - """.trimIndent(), - ) - db.execSQL( - """ - CREATE UNIQUE INDEX IF NOT EXISTS platoon_activity_unresolved_identity - ON platoon_activity(occurred_at, action_id, kind, member_name) - WHERE resolved_uid IS NULL - """.trimIndent(), - ) - createPlatoonActivityRetentionIndex(db) - } - - private fun createPlatoonActivityRetentionIndex(db: SQLiteDatabase) { - db.execSQL( - "CREATE INDEX IF NOT EXISTS platoon_activity_resolution_retention " + - "ON platoon_activity(resolved_uid, captured_at DESC, id DESC)", - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS platoon_activity_retention_order " + - "ON platoon_activity(captured_at DESC, id DESC)", - ) - } - - private fun createPlatoonMaintenanceStateTable(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS platoon_maintenance_state ( - key TEXT PRIMARY KEY, - value INTEGER NOT NULL - ) - """.trimIndent(), - ) - } - - private fun latestSnapshotCapturedAt(db: SQLiteDatabase): Instant? = - db.rawQuery("SELECT MAX(captured_at) FROM snapshots", null).use { cursor -> - if (cursor.moveToFirst() && !cursor.isNull(0)) { - Instant.ofEpochMilli(cursor.getLong(0)) - } else { - null - } - } - - private fun resolveUidForName( - db: SQLiteDatabase, - memberName: String, - occurredAt: Instant, - ): Long? { - val windowStart = occurredAt.minusMillis(NAME_RESOLUTION_WINDOW_MILLIS).toEpochMilli() - val windowEnd = occurredAt.plusMillis(NAME_RESOLUTION_WINDOW_MILLIS).toEpochMilli() - val historical = db.rawQuery( - """ - SELECT DISTINCT sm.uid - FROM snapshot_members sm - JOIN snapshots s ON s.id = sm.snapshot_id - WHERE sm.name = ? COLLATE BINARY - AND s.captured_at >= ? - AND s.captured_at <= ? - LIMIT 2 - """.trimIndent(), - arrayOf(memberName, windowStart.toString(), windowEnd.toString()), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) add(cursor.getLong(0)) - } - } - if (historical.size == 1) return historical.single() - if (historical.size > 1) return null - - val current = db.rawQuery( - "SELECT uid FROM members WHERE current_name = ? COLLATE BINARY LIMIT 2", - arrayOf(memberName), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) add(cursor.getLong(0)) - } - } - return current.singleOrNull() - } - - private fun resolveUnresolvedActivityUids(db: SQLiteDatabase) { - createPlatoonMaintenanceStateTable(db) - val cursorId = db.rawQuery( - "SELECT value FROM platoon_maintenance_state WHERE key = ?", - arrayOf(ACTIVITY_RESOLUTION_CURSOR_KEY), - ).use { cursor -> - if (cursor.moveToFirst()) cursor.getLong(0) else 0L - } - val unresolved = readUnresolvedActivityBatch( - db = db, - selection = "resolved_uid IS NULL AND id > ?", - selectionArgs = arrayOf(cursorId.toString()), - limit = MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS, - ).toMutableList() - if (unresolved.size < MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS) { - unresolved += readUnresolvedActivityBatch( - db = db, - selection = "resolved_uid IS NULL AND id <= ?", - selectionArgs = arrayOf(cursorId.toString()), - limit = MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS - unresolved.size, - ) - } - unresolved.forEach { activity -> - val uid = resolveUidForName(db, activity.memberName, activity.occurredAt) - ?: return@forEach - val updated = db.updateWithOnConflict( - "platoon_activity", - ContentValues().apply { - put("resolved_uid", uid) - put("resolution", ActivityResolution.UNIQUE_ROSTER_NAME.name) - }, - "id = ? AND resolved_uid IS NULL", - arrayOf(activity.id.toString()), - SQLiteDatabase.CONFLICT_IGNORE, - ) - if (updated == 0) { - db.delete( - "platoon_activity", - """ - id = ? AND EXISTS ( - SELECT 1 - FROM platoon_activity existing - WHERE existing.id != ? - AND existing.occurred_at = ? - AND existing.action_id = ? - AND existing.kind = ? - AND existing.resolved_uid = ? - ) - """.trimIndent(), - arrayOf( - activity.id.toString(), - activity.id.toString(), - activity.occurredAt.toEpochMilli().toString(), - activity.actionId.toString(), - activity.kind.toString(), - uid.toString(), - ), - ) - } - } - unresolved.lastOrNull()?.let { activity -> - db.insertWithOnConflict( - "platoon_maintenance_state", - null, - ContentValues().apply { - put("key", ACTIVITY_RESOLUTION_CURSOR_KEY) - put("value", activity.id) - }, - SQLiteDatabase.CONFLICT_REPLACE, - ) - } - } - - private fun readUnresolvedActivityBatch( - db: SQLiteDatabase, - selection: String, - selectionArgs: Array, - limit: Int, - ): List { - if (limit <= 0) return emptyList() - return db.query( - "platoon_activity", - arrayOf("id", "occurred_at", "member_name", "action_id", "kind"), - selection, - selectionArgs, - null, - null, - "id", - limit.toString(), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - UnresolvedActivity( - id = cursor.getLong(0), - occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), - memberName = cursor.getString(2), - actionId = cursor.getLong(3), - kind = cursor.getLong(4), - ), - ) - } - } - } - } - - // Function Name: trimPlatoonActivity - // Description: - // - Caps cumulative network-derived activity evidence to a bounded newest-first history. - // - Runs on database open and after each ingest so an older oversized database self-heals. - // Parameters: - // - db: Writable database participating in the caller's transaction when applicable. - // Returns: - // - Unit after deleting only rows beyond the retained observation limit. - private fun trimPlatoonActivity(db: SQLiteDatabase) { - db.execSQL( - """ - DELETE FROM platoon_activity - WHERE id IN ( - SELECT id - FROM platoon_activity - ORDER BY captured_at DESC, id DESC - LIMIT -1 OFFSET $MAX_STORED_ACTIVITY_OBSERVATIONS - ) - """.trimIndent(), - ) - } - - private fun ensureRosterActiveMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - source: EvidenceSource, - ) { - val hasOpenMembershipPeriod = db.rawQuery( - "SELECT 1 FROM membership_periods WHERE uid = ? AND left_at IS NULL LIMIT 1", - arrayOf(uid.toString()), - ).use(Cursor::moveToFirst) - if (hasOpenMembershipPeriod) return - insertMembershipPeriod( - db = db, - uid = uid, - joinedAt = null, - precision = EvidencePrecision.UNKNOWN, - source = source, - ) - } - - private fun correlateMembershipBoundary( - db: SQLiteDatabase, - membershipPeriodId: Long, - member: SnapshotMember, - type: MemberEventType, - boundary: MembershipBoundary, - observedAt: Instant, - from: Instant?, - nameIsUnique: Boolean, - ) { - if (!nameIsUnique || from == null) return - val candidates = readMembershipActivityCandidates( - db = db, - memberName = member.name, - from = from, - until = observedAt, - ) - val occurredAt = candidates.map(ActivityCandidate::occurredAt).distinct().singleOrNull() - ?: return - applyExactMembershipBoundary( - db = db, - membershipPeriodId = membershipPeriodId, - uid = member.uid, - memberName = member.name, - type = type, - boundary = boundary, - occurredAt = occurredAt, - observedAt = observedAt, - activityIds = candidates.map(ActivityCandidate::id), - ) - } - - private fun readMembershipActivityCandidates( - db: SQLiteDatabase, - memberName: String, - from: Instant, - until: Instant, - ): List = db.rawQuery( - """ - SELECT id, occurred_at - FROM platoon_activity - WHERE member_name = ? COLLATE BINARY - AND occurred_at > ? - AND occurred_at <= ? - AND action_id NOT IN (?, ?) - AND member_event_id IS NULL - ORDER BY occurred_at, id - """.trimIndent(), - arrayOf( - memberName, - from.toEpochMilli().toString(), - until.toEpochMilli().toString(), - DAILY_PATROL_REWARD_ACTION_ID.toString(), - DAILY_PATROL_RELATED_ACTION_ID.toString(), - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add(ActivityCandidate(cursor.getLong(0), Instant.ofEpochMilli(cursor.getLong(1)))) - } - } - } - - private fun reconcileInferredMembershipBoundaries(db: SQLiteDatabase) { - val activities = db.rawQuery( - """ - SELECT id, occurred_at, member_name, resolved_uid - FROM platoon_activity - WHERE member_event_id IS NULL - AND resolved_uid IS NOT NULL - AND action_id NOT IN (?, ?) - ORDER BY occurred_at, id - """.trimIndent(), - arrayOf( - DAILY_PATROL_REWARD_ACTION_ID.toString(), - DAILY_PATROL_RELATED_ACTION_ID.toString(), - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - ResolvedActivityCandidate( - id = cursor.getLong(0), - occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), - memberName = cursor.getString(2), - uid = cursor.getLong(3), - ), - ) - } - } - } - activities.forEach { activity -> - val boundaries = readNearbyInferredBoundaries(db, activity) - val boundary = boundaries.singleOrNull() ?: return@forEach - val eventType = when (boundary.boundary) { - MembershipBoundary.WITHDRAW -> MemberEventType.LEFT - MembershipBoundary.JOIN -> { - if (hasEarlierMembershipPeriod(db, boundary.uid, boundary.membershipPeriodId)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - } - } - } - applyExactMembershipBoundary( - db = db, - membershipPeriodId = boundary.membershipPeriodId, - uid = boundary.uid, - memberName = activity.memberName, - type = eventType, - boundary = boundary.boundary, - occurredAt = activity.occurredAt, - observedAt = boundary.inferredAt, - activityIds = listOf(activity.id), - ) - } - } - - private fun readNearbyInferredBoundaries( - db: SQLiteDatabase, - activity: ResolvedActivityCandidate, - ): List = db.rawQuery( - """ - SELECT id, uid, joined_at, left_at, joined_precision, left_precision - FROM membership_periods - WHERE uid = ? - AND ( - (joined_at IS NOT NULL AND joined_precision = ? AND ABS(joined_at - ?) <= ?) - OR - (left_at IS NOT NULL AND left_precision = ? AND ABS(left_at - ?) <= ?) - ) - """.trimIndent(), - arrayOf( - activity.uid.toString(), - EvidencePrecision.INFERRED.name, - activity.occurredAt.toEpochMilli().toString(), - MEMBERSHIP_CORRELATION_WINDOW_MILLIS.toString(), - EvidencePrecision.INFERRED.name, - activity.occurredAt.toEpochMilli().toString(), - MEMBERSHIP_CORRELATION_WINDOW_MILLIS.toString(), - ), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - val membershipPeriodId = cursor.getLong(0) - val uid = cursor.getLong(1) - val joinedAt = cursor.getNullableLong(2) - val leftAt = cursor.getNullableLong(3) - if (joinedAt != null && - cursor.getString(4) == EvidencePrecision.INFERRED.name && - kotlin.math.abs(joinedAt - activity.occurredAt.toEpochMilli()) <= - MEMBERSHIP_CORRELATION_WINDOW_MILLIS - ) { - add( - InferredBoundary( - membershipPeriodId, - uid, - MembershipBoundary.JOIN, - Instant.ofEpochMilli(joinedAt), - ), - ) - } - if (leftAt != null && - cursor.getNullableString(5) == EvidencePrecision.INFERRED.name && - kotlin.math.abs(leftAt - activity.occurredAt.toEpochMilli()) <= - MEMBERSHIP_CORRELATION_WINDOW_MILLIS - ) { - add( - InferredBoundary( - membershipPeriodId, - uid, - MembershipBoundary.WITHDRAW, - Instant.ofEpochMilli(leftAt), - ), - ) - } - } - } - } - - private fun applyExactMembershipBoundary( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - memberName: String, - type: MemberEventType, - boundary: MembershipBoundary, - occurredAt: Instant, - observedAt: Instant, - activityIds: List, - ) { - val values = ContentValues().apply { - when (boundary) { - MembershipBoundary.JOIN -> { - put("joined_at", occurredAt.toEpochMilli()) - putNull("joined_date") - put("joined_time_known", 1) - put("joined_precision", EvidencePrecision.EXACT.name) - put("joined_source", EvidenceSource.GAME_UPDATES.name) - } - MembershipBoundary.WITHDRAW -> { - put("left_at", occurredAt.toEpochMilli()) - putNull("left_date") - put("left_time_known", 1) - put("left_precision", EvidencePrecision.EXACT.name) - put("left_source", EvidenceSource.GAME_UPDATES.name) - } - } - } - db.update("membership_periods", values, "id = ?", arrayOf(membershipPeriodId.toString())) - val inferredTypes = when (boundary) { - MembershipBoundary.JOIN -> arrayOf( - MemberEventType.JOINED.name, - MemberEventType.REJOINED.name, - ) - MembershipBoundary.WITHDRAW -> arrayOf( - MemberEventType.LEFT.name, - MemberEventType.REMOVED.name, - ) - } - db.delete( - "member_events", - "membership_period_id = ? AND source IN (?, ?) AND event_type IN (?, ?)", - arrayOf( - membershipPeriodId.toString(), - EvidenceSource.SNAPSHOT.name, - EvidenceSource.LEGACY_IMPORT.name, - inferredTypes[0], - inferredTypes[1], - ), - ) - val eventId = insertEvent( - db = db, - uid = uid, - type = type, - occurredAt = occurredAt, - observedAt = observedAt, - precision = EvidencePrecision.EXACT, - source = EvidenceSource.GAME_UPDATES, - note = memberName, - membershipPeriodId = membershipPeriodId, - ) - activityIds.forEach { activityId -> - db.update( - "platoon_activity", - ContentValues().apply { - put("resolved_uid", uid) - put("resolution", ActivityResolution.MEMBERSHIP_CORRELATION.name) - put("member_event_id", eventId) - }, - "id = ? AND member_event_id IS NULL", - arrayOf(activityId.toString()), - ) - } - } - - private fun insertManualMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - joined: MembershipBoundaryValue, - withdrew: MembershipBoundaryValue?, - note: String, - ): Long = db.insertOrThrow( - "membership_periods", - null, - ContentValues().apply { - put("uid", uid) - putMembershipBoundary("joined", joined) - put("joined_precision", EvidencePrecision.MANUAL.name) - put("joined_source", EvidenceSource.MANUAL.name) - if (withdrew == null) { - clearMembershipBoundary("left") - putNull("left_precision") - putNull("left_source") - } else { - putMembershipBoundary("left", withdrew) - put("left_precision", EvidencePrecision.MANUAL.name) - put("left_source", EvidenceSource.MANUAL.name) - } - put("note", note.trim()) - }, - ) - - private fun replaceManualMembershipPeriodEvents( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - memberName: String, - joined: MembershipBoundaryValue?, - withdrew: MembershipBoundaryValue?, - ) { - db.delete( - "member_events", - "membership_period_id = ? AND source = ?", - arrayOf(membershipPeriodId.toString(), EvidenceSource.MANUAL.name), - ) - val observedAt = Instant.now() - if (joined != null) { - insertEvent( - db = db, - uid = uid, - type = if (hasEarlierMembershipPeriod(db, uid, membershipPeriodId)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - }, - occurredAt = joined.instant, - eventDate = joined.date, - timeKnown = joined.timeKnown, - observedAt = observedAt, - precision = EvidencePrecision.MANUAL, - source = EvidenceSource.MANUAL, - note = memberName, - membershipPeriodId = membershipPeriodId, - ) - } - if (withdrew != null) { - insertEvent( - db = db, - uid = uid, - type = MemberEventType.LEFT, - occurredAt = withdrew.instant, - eventDate = withdrew.date, - timeKnown = withdrew.timeKnown, - observedAt = observedAt, - precision = EvidencePrecision.MANUAL, - source = EvidenceSource.MANUAL, - note = memberName, - membershipPeriodId = membershipPeriodId, - ) - } - } - - private fun linkLegacyMembershipPeriodEvents(db: SQLiteDatabase) { - db.rawQuery( - """ - SELECT t.id, t.uid, t.joined_at, t.left_at, - t.joined_precision, t.left_precision, - t.joined_source, t.left_source, - COALESCE(m.custom_name, m.current_name) - FROM membership_periods t - JOIN members m ON m.uid = t.uid - ORDER BY t.uid, t.id - """.trimIndent(), - null, - ).use { cursor -> - while (cursor.moveToNext()) { - val membershipPeriodId = cursor.getLong(0) - val uid = cursor.getLong(1) - val joinedAt = cursor.getNullableLong(2) - val leftAt = cursor.getNullableLong(3) - val joinedPrecision = EvidencePrecision.valueOf(cursor.getString(4)) - val leftPrecision = cursor.getNullableString(5)?.let(EvidencePrecision::valueOf) - val joinedSource = EvidenceSource.valueOf(cursor.getString(6)) - val leftSource = cursor.getNullableString(7)?.let(EvidenceSource::valueOf) - val memberName = cursor.getString(8) - if (joinedAt != null) { - val expectedType = if (hasEarlierMembershipPeriod(db, uid, membershipPeriodId)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - } - val linked = linkLegacyBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriodId, - uid = uid, - occurredAt = joinedAt, - precision = joinedPrecision, - source = joinedSource, - expectedType = expectedType, - ) - if (!linked && joinedSource == EvidenceSource.MANUAL) { - insertLegacyManualBoundaryEvent( - db, - membershipPeriodId, - uid, - memberName, - expectedType, - joinedAt, - joinedPrecision, - ) - } - } - if (leftAt != null && leftPrecision != null && leftSource != null) { - val linked = linkLegacyBoundaryEvent( - db = db, - membershipPeriodId = membershipPeriodId, - uid = uid, - occurredAt = leftAt, - precision = leftPrecision, - source = leftSource, - expectedType = MemberEventType.LEFT, - ) - if (!linked && leftSource == EvidenceSource.MANUAL) { - insertLegacyManualBoundaryEvent( - db, - membershipPeriodId, - uid, - memberName, - MemberEventType.LEFT, - leftAt, - leftPrecision, - ) - } - } - } - } - } - - private fun linkLegacyBoundaryEvent( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - occurredAt: Long, - precision: EvidencePrecision, - source: EvidenceSource, - expectedType: MemberEventType, - ): Boolean { - 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 = ? - AND precision = ? - ORDER BY id - """.trimIndent(), - arrayOf( - uid.toString(), - source.name, - precision.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.selectCandidate( - expectedType, - occurredAt, - candidates, - ) - ?: return false - db.update( - "member_events", - ContentValues().apply { put("membership_period_id", membershipPeriodId) }, - "id = ?", - arrayOf(eventId.toString()), - ) - return true - } - - private fun insertLegacyManualBoundaryEvent( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - memberName: String, - type: MemberEventType, - occurredAt: Long, - precision: EvidencePrecision, - ) { - val boundary = Instant.ofEpochMilli(occurredAt) - insertEvent( - db = db, - uid = uid, - type = type, - occurredAt = boundary, - observedAt = boundary, - precision = precision, - source = EvidenceSource.MANUAL, - note = memberName, - membershipPeriodId = membershipPeriodId, - ) - } - - private fun backfillManualCalendarDates(db: SQLiteDatabase, zoneId: ZoneId) { - val membershipPeriods = db.rawQuery( - """ - SELECT id, joined_at, left_at, joined_source, left_source - FROM membership_periods - WHERE (joined_source = ? AND joined_at IS NOT NULL) - OR (left_source = ? AND left_at IS NOT NULL) - """.trimIndent(), - arrayOf(EvidenceSource.MANUAL.name, EvidenceSource.MANUAL.name), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - ManualBoundaryDateBackfill( - id = cursor.getLong(0), - joinedAtMillis = cursor.getNullableLong(1), - leftAtMillis = cursor.getNullableLong(2), - joinedIsManual = cursor.getString(3) == EvidenceSource.MANUAL.name, - leftIsManual = - cursor.getNullableString(4) == EvidenceSource.MANUAL.name, - ), - ) - } - } - } - membershipPeriods.forEach { membershipPeriod -> - db.update( - "membership_periods", - ContentValues().apply { - if (membershipPeriod.joinedIsManual && membershipPeriod.joinedAtMillis != null) { - put( - "joined_date", - migrationCalendarDate(membershipPeriod.joinedAtMillis, zoneId).toEpochDay(), - ) - put("joined_time_known", 1) - } - if (membershipPeriod.leftIsManual && membershipPeriod.leftAtMillis != null) { - put( - "left_date", - migrationCalendarDate(membershipPeriod.leftAtMillis, zoneId).toEpochDay(), - ) - put("left_time_known", 1) - } - }, - "id = ?", - arrayOf(membershipPeriod.id.toString()), - ) - } - - val events = db.rawQuery( - """ - SELECT id, occurred_at - FROM member_events - WHERE source = ? - AND occurred_at IS NOT NULL - """.trimIndent(), - arrayOf(EvidenceSource.MANUAL.name), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) add(cursor.getLong(0) to cursor.getLong(1)) - } - } - events.forEach { (id, occurredAtMillis) -> - db.update( - "member_events", - ContentValues().apply { - put( - "event_date", - migrationCalendarDate(occurredAtMillis, zoneId).toEpochDay(), - ) - put("time_known", 1) - }, - "id = ?", - arrayOf(id.toString()), - ) - } - } - - private fun backfillSnapshotMembershipPeriodEvents(db: SQLiteDatabase) { - db.rawQuery( - """ - SELECT t.id, t.uid, t.joined_at, t.left_at, t.joined_source, t.left_source, - COALESCE(m.custom_name, m.current_name) - FROM membership_periods t - JOIN members m ON m.uid = t.uid - WHERE t.joined_source IN (?, ?) - OR t.left_source IN (?, ?) - ORDER BY t.uid, t.id - """.trimIndent(), - arrayOf( - EvidenceSource.SNAPSHOT.name, - EvidenceSource.LEGACY_IMPORT.name, - EvidenceSource.SNAPSHOT.name, - EvidenceSource.LEGACY_IMPORT.name, - ), - ).use { cursor -> - while (cursor.moveToNext()) { - val membershipPeriodId = cursor.getLong(0) - val uid = cursor.getLong(1) - val joinedAt = cursor.getNullableLong(2)?.let(Instant::ofEpochMilli) - val leftAt = cursor.getNullableLong(3)?.let(Instant::ofEpochMilli) - val joinedSource = EvidenceSource.valueOf(cursor.getString(4)) - val leftSource = cursor.getNullableString(5)?.let(EvidenceSource::valueOf) - val name = cursor.getString(6) - if (joinedAt != null && joinedSource in SNAPSHOT_EVENT_SOURCES) { - insertSnapshotBoundaryEvent( - db, - membershipPeriodId, - uid, - name, - if (hasEarlierMembershipPeriod(db, uid, membershipPeriodId)) { - MemberEventType.REJOINED - } else { - MemberEventType.JOINED - }, - joinedAt, - joinedSource, - ) - } - if (leftAt != null && leftSource in SNAPSHOT_EVENT_SOURCES) { - insertSnapshotBoundaryEvent( - db, - membershipPeriodId, - uid, - name, - MemberEventType.LEFT, - leftAt, - requireNotNull(leftSource), - ) - } - } - } - } - - private fun insertSnapshotBoundaryEvent( - db: SQLiteDatabase, - membershipPeriodId: Long, - uid: Long, - memberName: String, - type: MemberEventType, - observedAt: Instant, - source: EvidenceSource, - ) { - val compatibleTypes = when (type) { - MemberEventType.JOINED, - MemberEventType.REJOINED, - -> arrayOf(MemberEventType.JOINED.name, MemberEventType.REJOINED.name) - else -> arrayOf(type.name, type.name) - } - val exists = db.rawQuery( - "SELECT 1 FROM member_events " + - "WHERE membership_period_id = ? AND event_type IN (?, ?) LIMIT 1", - arrayOf(membershipPeriodId.toString(), compatibleTypes[0], compatibleTypes[1]), - ).use { cursor -> cursor.moveToFirst() } - if (exists) return - insertEvent( - db = db, - uid = uid, - type = type, - occurredAt = null, - observedAt = observedAt, - precision = EvidencePrecision.INFERRED, - source = source, - note = memberName, - membershipPeriodId = membershipPeriodId, - ) - } - - private fun hasEarlierMembershipPeriod(db: SQLiteDatabase, uid: Long, membershipPeriodId: Long): Boolean = - db.rawQuery( - """ - SELECT 1 - FROM membership_periods current - JOIN membership_periods other ON other.uid = current.uid AND other.id != current.id - WHERE current.id = ? - AND current.uid = ? - AND ( - (current.joined_at IS NOT NULL AND other.joined_at IS NOT NULL AND - (other.joined_at < current.joined_at OR - (other.joined_at = current.joined_at AND other.id < current.id))) - OR - (current.joined_at IS NULL AND other.id < current.id) - ) - LIMIT 1 - """.trimIndent(), - arrayOf(membershipPeriodId.toString(), uid.toString()), - ).use(Cursor::moveToFirst) - - private fun memberExists(db: SQLiteDatabase, uid: Long): Boolean = - db.rawQuery( - "SELECT 1 FROM members WHERE uid = ? LIMIT 1", - arrayOf(uid.toString()), - ).use(Cursor::moveToFirst) - - private fun memberName(db: SQLiteDatabase, uid: Long): String? = - db.rawQuery( - "SELECT COALESCE(custom_name, current_name) FROM members WHERE uid = ?", - arrayOf(uid.toString()), - ).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } - - private fun readMembershipPeriodForUpdate(db: SQLiteDatabase, membershipPeriodId: Long): MembershipPeriodForUpdate? = - db.rawQuery( - """ - SELECT uid, joined_at, left_at, joined_date, left_date, - joined_time_known, left_time_known, joined_source, left_source - FROM membership_periods - WHERE id = ? - """.trimIndent(), - arrayOf(membershipPeriodId.toString()), - ).use { cursor -> - if (!cursor.moveToFirst()) { - null - } else { - MembershipPeriodForUpdate( - uid = cursor.getLong(0), - joined = cursor.membershipBoundaryValue( - instantIndex = 1, - dateIndex = 3, - timeKnownIndex = 5, - zoneId = GameTimeZonePreferences.get(appContext, storageScope.storageId), - ), - left = cursor.membershipBoundaryValue( - instantIndex = 2, - dateIndex = 4, - timeKnownIndex = 6, - zoneId = GameTimeZonePreferences.get(appContext, storageScope.storageId), - ), - joinedSource = EvidenceSource.valueOf(cursor.getString(7)), - leftSource = cursor.getNullableString(8)?.let(EvidenceSource::valueOf), - ) - } - } - - private fun insertSnapshotMember( - db: SQLiteDatabase, - snapshotId: Long, - member: SnapshotMember, - ) { - db.insertOrThrow( - "snapshot_members", - null, - ContentValues().apply { - put("snapshot_id", snapshotId) - put("uid", member.uid) - put("name", member.name) - put("level", member.level) - put("weekly_merit", member.weeklyMerit) - put("total_merit", member.totalMerit) - put("high_score", member.highScore) - put("total_score", member.totalScore) - put("last_login", member.lastLogin) - }, - ) - } - - private fun upsertMember(db: SQLiteDatabase, member: SnapshotMember, observedAt: Instant) { - val existing = ContentValues().apply { - put("current_name", member.name) - put("current_level", member.level) - put("is_active", 1) - put("last_seen_at", observedAt.toEpochMilli()) - } - if (db.update("members", existing, "uid = ?", arrayOf(member.uid.toString())) == 0) { - existing.put("uid", member.uid) - existing.put("first_seen_at", observedAt.toEpochMilli()) - db.insertOrThrow("members", null, existing) - } - } - - private fun markInactive(db: SQLiteDatabase, uid: Long, observedAt: Instant) { - db.update( - "members", - ContentValues().apply { - put("is_active", 0) - put("last_seen_at", observedAt.toEpochMilli()) - }, - "uid = ?", - arrayOf(uid.toString()), - ) - } - - private fun insertMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - joinedAt: Instant?, - precision: EvidencePrecision, - source: EvidenceSource, - ): Long = db.insertOrThrow( - "membership_periods", - null, - ContentValues().apply { - put("uid", uid) - putNullableLong("joined_at", joinedAt?.toEpochMilli()) - put("joined_precision", precision.name) - put("joined_source", source.name) - }, - ) - - private fun closeLatestMembershipPeriod( - db: SQLiteDatabase, - uid: Long, - leftAt: Instant, - precision: EvidencePrecision, - source: EvidenceSource, - ): Long { - 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 membershipPeriod") - db.update( - "membership_periods", - ContentValues().apply { - put("left_at", leftAt.toEpochMilli()) - putNull("left_date") - put("left_time_known", 1) - put("left_precision", precision.name) - put("left_source", source.name) - }, - "id = ?", - arrayOf(membershipPeriodId.toString()), - ) - return membershipPeriodId - } - - private fun insertEvent( - db: SQLiteDatabase, - uid: Long, - type: MemberEventType, - occurredAt: Instant?, - eventDate: LocalDate? = null, - timeKnown: Boolean = true, - observedAt: Instant, - precision: EvidencePrecision, - source: EvidenceSource, - note: String, - membershipPeriodId: Long? = null, - ): Long = db.insertOrThrow( - "member_events", - null, - ContentValues().apply { - put("uid", uid) - putNullableLong("membership_period_id", membershipPeriodId) - put("event_type", type.name) - putNullableLong("occurred_at", occurredAt?.toEpochMilli()) - putNullableLong("event_date", eventDate?.toEpochDay()) - put("time_known", if (timeKnown) 1 else 0) - put("observed_at", observedAt.toEpochMilli()) - put("precision", precision.name) - put("source", source.name) - put("note", note) - }, - ) - - private fun readKnownMembers(db: SQLiteDatabase): List = - db.rawQuery( - """ - SELECT m.uid, m.current_name, m.is_active, - EXISTS(SELECT 1 FROM membership_periods t WHERE t.uid = m.uid) - FROM members m - """.trimIndent(), - null, - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - SnapshotReconciler.KnownMember( - uid = cursor.getLong(0), - name = cursor.getString(1), - isActive = cursor.getInt(2) != 0, - hasPriorMembershipPeriod = cursor.getInt(3) != 0, - ), - ) - } - } - } - - - private fun readMembershipPeriods(db: SQLiteDatabase, uid: Long): List = - db.query( - "membership_periods", - MEMBERSHIP_PERIOD_COLUMNS, - "uid = ?", - arrayOf(uid.toString()), - null, - null, - "CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, joined_at, id", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - MembershipPeriod( - id = cursor.getLong(0), - uid = uid, - joinedAt = cursor.getNullableLong(1)?.let(Instant::ofEpochMilli), - leftAt = cursor.getNullableLong(2)?.let(Instant::ofEpochMilli), - joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), - leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), - joinedTimeKnown = cursor.getInt(5) != 0, - leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 }, - joinedPrecision = enumValueOf(cursor.getString(7)), - leftPrecision = cursor.getNullableString(8) - ?.let(EvidencePrecision::valueOf), - joinedSource = enumValueOf(cursor.getString(9)), - leftSource = cursor.getNullableString(10) - ?.let(EvidenceSource::valueOf), - note = cursor.getString(11), - ), - ) - } - } - } - - private fun sourceFileExists(db: SQLiteDatabase, sourceFile: String): Boolean = - db.rawQuery( - "SELECT 1 FROM snapshots WHERE source_file = ? LIMIT 1", - arrayOf(sourceFile), - ).use { cursor -> cursor.moveToFirst() } - - private fun count(db: SQLiteDatabase, table: String): Long = - db.rawQuery("SELECT COUNT(*) FROM $table", null).use { cursor -> - cursor.moveToFirst() - cursor.getLong(0) - } - - private fun Cursor.toMemberEvent(): MemberEvent = MemberEvent( - id = getLong(0), - uid = getLong(1), - type = enumValueOf(getString(2)), - occurredAt = getNullableLong(3)?.let(Instant::ofEpochMilli), - eventDate = getNullableLong(4)?.let(LocalDate::ofEpochDay), - timeKnown = getInt(5) != 0, - observedAt = Instant.ofEpochMilli(getLong(6)), - precision = enumValueOf(getString(7)), - source = enumValueOf(getString(8)), - note = getString(9), - ) - - private enum class MembershipBoundary { - JOIN, - WITHDRAW, - } - - private data class ActivityCandidate( - val id: Long, - val occurredAt: Instant, - ) - - private data class ResolvedActivityCandidate( - val id: Long, - val occurredAt: Instant, - val memberName: String, - val uid: Long, - ) - - private data class UnresolvedActivity( - val id: Long, - val occurredAt: Instant, - val memberName: String, - val actionId: Long, - val kind: Long, - ) - - private data class InferredBoundary( - val membershipPeriodId: Long, - val uid: Long, - val boundary: MembershipBoundary, - val inferredAt: Instant, - ) - - private data class BoundaryEventCandidate( - val id: Long, - val membershipPeriodId: Long?, - val boundaryAt: Instant, - ) - - private data class MembershipPeriodForUpdate( - val uid: Long, - val joined: MembershipBoundaryValue?, - val left: MembershipBoundaryValue?, - val joinedSource: EvidenceSource, - val leftSource: EvidenceSource?, - ) - - - private data class ShadowMembershipPeriod( - val joinedAt: Long?, - val leftAt: Long?, - val joinedDate: Long?, - val leftDate: Long?, - val joinedTimeKnown: Int?, - val leftTimeKnown: Int?, - val joinedPrecision: String?, - val leftPrecision: String?, - val joinedSource: String?, - val leftSource: String?, - ) - - private data class ManualBoundaryDateBackfill( - val id: Long, - val joinedAtMillis: Long?, - val leftAtMillis: Long?, - val joinedIsManual: Boolean, - val leftIsManual: Boolean, - ) - - private data class RosterIdentity( - val name: String, - val level: Int, - val capturedAt: Instant, - ) - - private data class RosterFrame( - val capturedAt: Instant, - val members: Map, - ) - - private data class PreservedMembershipPeriodOverlap( - val id: Long, - val joinedAt: Instant?, - val joinedPrecision: EvidencePrecision, - val joinedSource: EvidenceSource, - val leftAt: Instant?, - val leftPrecision: EvidencePrecision?, - val leftSource: EvidenceSource?, - ) - - private data class RosterPresenceRun( - val firstSeenAt: Instant, - val lastSeenAt: Instant, - val joinedAt: Instant?, - val leftAt: Instant?, - val name: String, - ) + internal fun replaceWeeklyReportHistory(replacements: List) = + WeeklyHistoryStore.replace(writableDatabase, replacements) companion object { /** A fresh current-schema database that never touches storage, for schema comparison. */ @@ -4043,142 +270,5 @@ internal class PlatoonDatabase private constructor( "weekly_notes", "weekly_overrides", ) - private const val MAX_UNRESOLVED_ACTIVITY_RESOLUTIONS = 250 - private const val ACTIVITY_RESOLUTION_CURSOR_KEY = "activity_resolution_cursor" - private const val DAILY_PATROL_RELATED_ACTION_ID = 801005L - private const val NAME_RESOLUTION_WINDOW_MILLIS = 30L * 24L * 60L * 60L * 1000L - private const val MEMBERSHIP_CORRELATION_WINDOW_MILLIS = 12L * 60L * 60L * 1000L - private const val EXACT_UPDATE_CORRELATION_WINDOW_MILLIS = - 48L * 60L * 60L * 1000L - private val SNAPSHOT_EVENT_SOURCES = setOf( - EvidenceSource.SNAPSHOT, - EvidenceSource.LEGACY_IMPORT, - ) - private val SNAPSHOT_COLUMNS = arrayOf("id", "captured_at", "source_file", "game_version") - private val MEMBER_COLUMNS = arrayOf( - "uid", - "COALESCE(custom_name, current_name)", - "current_level", - "is_active", - "first_seen_at", - "last_seen_at", - "note", - ) - private val EVENT_COLUMNS = arrayOf( - "id", - "uid", - "event_type", - "occurred_at", - "event_date", - "time_known", - "observed_at", - "precision", - "source", - "note", - ) - private val SNAPSHOT_MEMBER_COLUMNS = arrayOf( - "uid", - "name", - "level", - "weekly_merit", - "total_merit", - "high_score", - "total_score", - "last_login", - ) - private val MEMBERSHIP_PERIOD_COLUMNS = arrayOf( - "id", - "joined_at", - "left_at", - "joined_date", - "left_date", - "joined_time_known", - "left_time_known", - "joined_precision", - "left_precision", - "joined_source", - "left_source", - "note", - ) - private val WEEKLY_NOTE_COLUMNS = arrayOf( - "id", - "period_start", - "game_day", - "text", - "event_id", - "is_automatic", - ) - private val WEEKLY_OVERRIDE_COLUMNS = arrayOf( - "uid", - "period_start", - "game_day", - "merit_delta", - "score_delta", - "attempts", - "attended", - "daily_patrol", - ) } } - -internal data class WeeklyReportHistoryReplacement( - val periodStartEpochDay: Long, - val recordedAt: Instant, - val fingerprint: String, - val payload: ByteArray, -) - -private fun ContentValues.putNullableLong(key: String, value: Long?) { - if (value == null) putNull(key) else put(key, value) -} - -private fun ContentValues.putMembershipBoundary( - prefix: String, - boundary: MembershipBoundaryValue, -) { - put("${prefix}_at", boundary.instant.toEpochMilli()) - put("${prefix}_date", boundary.date.toEpochDay()) - put("${prefix}_time_known", if (boundary.timeKnown) 1 else 0) -} - -private fun ContentValues.clearMembershipBoundary(prefix: String) { - putNull("${prefix}_at") - putNull("${prefix}_date") - putNull("${prefix}_time_known") -} - -private fun ContentValues.putNullableInt(key: String, value: Int?) { - if (value == null) putNull(key) else put(key, value) -} - -private fun ContentValues.putNullableBoolean(key: String, value: Boolean?) { - if (value == null) putNull(key) else put(key, if (value) 1 else 0) -} - -private fun Cursor.getNullableLong(index: Int): Long? = - if (isNull(index)) null else getLong(index) - -private fun Cursor.getNullableInt(index: Int): Int? = - if (isNull(index)) null else getInt(index) - -private fun Cursor.membershipBoundaryValue( - instantIndex: Int, - dateIndex: Int, - timeKnownIndex: Int, - zoneId: ZoneId, -): MembershipBoundaryValue? { - val instant = getNullableLong(instantIndex)?.let(Instant::ofEpochMilli) ?: return null - val date = getNullableLong(dateIndex)?.let(LocalDate::ofEpochDay) - ?: instant.atZone(zoneId).toLocalDate() - return MembershipBoundaryValue( - date = date, - instant = instant, - timeKnown = getNullableInt(timeKnownIndex)?.let { it != 0 } ?: true, - ) -} - -private fun Cursor.getNullableBoolean(index: Int): Boolean? = - if (isNull(index)) null else getInt(index) != 0 - -private fun Cursor.getNullableString(index: Int): String? = - if (isNull(index)) null else getString(index) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt new file mode 100644 index 0000000..7ff54c8 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt @@ -0,0 +1,462 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** Upgrades older schemas inside SQLiteOpenHelper's upgrade transaction. */ +internal object PlatoonDatabaseMigrations { + fun upgrade(db: SQLiteDatabase, oldVersion: Int, gameZone: () -> ZoneId) { + if (oldVersion < 10) renameTenuresToMembershipPeriods(db, oldVersion) + if (oldVersion < 9) addCalendarDateColumns(db) + if (oldVersion < 2) moveWeeklyNotesToSundayPeriods(db) + if (oldVersion < 3) PlatoonDatabaseSchema.createWeeklyOverridesTable(db) + if (oldVersion < 4) db.execSQL("ALTER TABLE members ADD COLUMN custom_name TEXT") + if (oldVersion < 5) copyAutomaticNoteNamesToEvents(db) + if (oldVersion < 6) { + db.execSQL( + "ALTER TABLE member_events ADD COLUMN membership_period_id INTEGER " + + "REFERENCES membership_periods(id)", + ) + PlatoonDatabaseSchema.createPlatoonActivityTable(db) + db.delete("weekly_notes", "is_automatic = 1", null) + linkLegacyMembershipPeriodEvents(db) + } + if (oldVersion < 7) backfillSnapshotMembershipPeriodEvents(db) + if (oldVersion < 9) backfillManualCalendarDates(db, gameZone()) + if (oldVersion < 11) { + PlatoonDatabaseSchema.createPlatoonMaintenanceStateTable(db) + PlatoonDatabaseSchema.createPlatoonActivityRetentionIndex(db) + } + if (oldVersion < 12) PlatoonDatabaseSchema.createWeeklyReportHistoryTables(db) + if (oldVersion < 13) MembershipRows.synchronizeAllMembershipStates(db) + } + + private fun renameTenuresToMembershipPeriods(db: SQLiteDatabase, oldVersion: Int) { + db.execSQL("ALTER TABLE tenures RENAME TO membership_periods") + db.execSQL("DROP INDEX IF EXISTS tenures_uid") + db.execSQL(PlatoonDatabaseSchema.MEMBERSHIP_PERIODS_UID_INDEX) + if (oldVersion >= 6) { + migrateMembershipPeriodEventReference( + db = db, + hasCalendarColumns = oldVersion >= 9, + ) + } + } + + private fun addCalendarDateColumns(db: SQLiteDatabase) { + db.execSQL("ALTER TABLE membership_periods ADD COLUMN joined_date INTEGER") + db.execSQL("ALTER TABLE membership_periods ADD COLUMN left_date INTEGER") + db.execSQL( + "ALTER TABLE membership_periods ADD COLUMN joined_time_known INTEGER NOT NULL DEFAULT 1", + ) + db.execSQL("ALTER TABLE membership_periods ADD COLUMN left_time_known INTEGER") + db.execSQL("ALTER TABLE member_events ADD COLUMN event_date INTEGER") + db.execSQL( + "ALTER TABLE member_events ADD COLUMN time_known INTEGER NOT NULL DEFAULT 1", + ) + } + + private fun moveWeeklyNotesToSundayPeriods(db: SQLiteDatabase) { + val correctedPeriods = mutableListOf>() + db.query( + "weekly_notes", + arrayOf("id", "game_day"), + null, + null, + null, + null, + null, + ).use { cursor -> + while (cursor.moveToNext()) { + correctedPeriods += cursor.getLong(0) to + PlatoonPeriods.weekStart(LocalDate.ofEpochDay(cursor.getLong(1))).toEpochDay() + } + } + correctedPeriods.forEach { (id, periodStart) -> + db.update( + "weekly_notes", + ContentValues().apply { put("period_start", periodStart) }, + "id = ?", + arrayOf(id.toString()), + ) + } + } + + private fun copyAutomaticNoteNamesToEvents(db: SQLiteDatabase) { + val historicalNames = mutableListOf>() + db.query( + "weekly_notes", + arrayOf("event_id", "text"), + "is_automatic = 1 AND event_id IS NOT NULL", + null, + null, + null, + "id", + ).use { cursor -> + while (cursor.moveToNext()) { + val parts = cursor.getString(1).split(':', limit = 3) + if (parts.size == 3 && parts[1].isNotBlank()) { + historicalNames += cursor.getLong(0) to parts[1] + } + } + } + historicalNames.forEach { (eventId, name) -> + db.update( + "member_events", + ContentValues().apply { put("note", name) }, + "id = ? AND note = ''", + arrayOf(eventId.toString()), + ) + } + } + + /** + * Rebuilds member_events with the membership-period reference, then rebuilds dependent tables + * so every foreign key targets the replacement. All rows and IDs are preserved. + */ + private fun migrateMembershipPeriodEventReference( + db: SQLiteDatabase, + hasCalendarColumns: Boolean, + ) { + db.execSQL("ALTER TABLE member_events RENAME TO member_events_legacy_v10") + db.execSQL("ALTER TABLE weekly_notes RENAME TO weekly_notes_legacy_v10") + db.execSQL("ALTER TABLE platoon_activity RENAME TO platoon_activity_legacy_v10") + db.execSQL("DROP INDEX IF EXISTS member_events_time") + db.execSQL("DROP INDEX IF EXISTS platoon_activity_member_time") + db.execSQL("DROP INDEX IF EXISTS platoon_activity_action_time") + db.execSQL("DROP INDEX IF EXISTS platoon_activity_exact_identity") + db.execSQL("DROP INDEX IF EXISTS platoon_activity_unresolved_identity") + + db.execSQL(PlatoonDatabaseSchema.memberEventsTable(withCalendarColumns = hasCalendarColumns)) + val calendarNames = if (hasCalendarColumns) ", event_date, time_known" else "" + db.execSQL( + """ + INSERT INTO member_events( + id, uid, membership_period_id, event_type, occurred_at$calendarNames, + observed_at, precision, source, note + ) + SELECT id, uid, tenure_id, event_type, occurred_at$calendarNames, + observed_at, precision, source, note + FROM member_events_legacy_v10 + """.trimIndent(), + ) + db.execSQL(PlatoonDatabaseSchema.MEMBER_EVENTS_TIME_INDEX) + + db.execSQL(PlatoonDatabaseSchema.WEEKLY_NOTES_TABLE) + db.execSQL( + """ + INSERT INTO weekly_notes(id, period_start, game_day, text, event_id, is_automatic) + SELECT id, period_start, game_day, text, event_id, is_automatic + FROM weekly_notes_legacy_v10 + """.trimIndent(), + ) + + PlatoonDatabaseSchema.createPlatoonActivityTable(db) + db.execSQL( + """ + INSERT OR IGNORE INTO platoon_activity( + id, occurred_at, action_id, kind, member_name, captured_at, + resolved_uid, resolution, member_event_id + ) + SELECT id, occurred_at, action_id, kind, member_name, captured_at, + resolved_uid, resolution, member_event_id + FROM platoon_activity_legacy_v10 + ORDER BY CASE WHEN member_event_id IS NULL THEN 1 ELSE 0 END, id + """.trimIndent(), + ) + db.execSQL("DROP TABLE weekly_notes_legacy_v10") + db.execSQL("DROP TABLE platoon_activity_legacy_v10") + db.execSQL("DROP TABLE member_events_legacy_v10") + } + + private fun linkLegacyMembershipPeriodEvents(db: SQLiteDatabase) { + db.rawQuery( + """ + SELECT period.id, period.uid, period.joined_at, period.left_at, + period.joined_precision, period.left_precision, + period.joined_source, period.left_source, + COALESCE(m.custom_name, m.current_name) + FROM membership_periods period + JOIN members m ON m.uid = period.uid + ORDER BY period.uid, period.id + """.trimIndent(), + null, + ).use { cursor -> + while (cursor.moveToNext()) { + val membershipPeriodId = cursor.getLong(0) + val uid = cursor.getLong(1) + val joinedAt = cursor.getNullableLong(2) + val leftAt = cursor.getNullableLong(3) + val joinedPrecision = EvidencePrecision.valueOf(cursor.getString(4)) + val leftPrecision = cursor.getNullableString(5)?.let(EvidencePrecision::valueOf) + val joinedSource = EvidenceSource.valueOf(cursor.getString(6)) + val leftSource = cursor.getNullableString(7)?.let(EvidenceSource::valueOf) + val memberName = cursor.getString(8) + if (joinedAt != null) { + val expectedType = MembershipRows.joinEventType(db, uid, membershipPeriodId) + val linked = linkLegacyBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriodId, + uid = uid, + occurredAt = joinedAt, + precision = joinedPrecision, + source = joinedSource, + expectedType = expectedType, + ) + if (!linked && joinedSource == EvidenceSource.MANUAL) { + insertLegacyManualBoundaryEvent( + db, + membershipPeriodId, + uid, + memberName, + expectedType, + joinedAt, + joinedPrecision, + ) + } + } + if (leftAt != null && leftPrecision != null && leftSource != null) { + val linked = linkLegacyBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriodId, + uid = uid, + occurredAt = leftAt, + precision = leftPrecision, + source = leftSource, + expectedType = MemberEventType.LEFT, + ) + if (!linked && leftSource == EvidenceSource.MANUAL) { + insertLegacyManualBoundaryEvent( + db, + membershipPeriodId, + uid, + memberName, + MemberEventType.LEFT, + leftAt, + leftPrecision, + ) + } + } + } + } + } + + private fun linkLegacyBoundaryEvent( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + occurredAt: Long, + precision: EvidencePrecision, + source: EvidenceSource, + expectedType: MemberEventType, + ): Boolean { + 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 = ? + AND precision = ? + ORDER BY id + """.trimIndent(), + arrayOf( + uid.toString(), + source.name, + precision.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.selectCandidate( + expectedType, + occurredAt, + candidates, + ) + ?: return false + db.update( + "member_events", + ContentValues().apply { put("membership_period_id", membershipPeriodId) }, + "id = ?", + arrayOf(eventId.toString()), + ) + return true + } + + private fun insertLegacyManualBoundaryEvent( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + type: MemberEventType, + occurredAt: Long, + precision: EvidencePrecision, + ) { + val boundary = Instant.ofEpochMilli(occurredAt) + MembershipRows.insertEvent( + db = db, + uid = uid, + type = type, + occurredAt = boundary, + observedAt = boundary, + precision = precision, + source = EvidenceSource.MANUAL, + note = memberName, + membershipPeriodId = membershipPeriodId, + ) + } + + private fun backfillManualCalendarDates(db: SQLiteDatabase, zoneId: ZoneId) { + val membershipPeriods = db.rawQuery( + """ + SELECT id, joined_at, left_at, joined_source, left_source + FROM membership_periods + WHERE (joined_source = ? AND joined_at IS NOT NULL) + OR (left_source = ? AND left_at IS NOT NULL) + """.trimIndent(), + arrayOf(EvidenceSource.MANUAL.name, EvidenceSource.MANUAL.name), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + ManualBoundaryDateBackfill( + id = cursor.getLong(0), + joinedAtMillis = cursor.getNullableLong(1), + leftAtMillis = cursor.getNullableLong(2), + joinedIsManual = cursor.getString(3) == EvidenceSource.MANUAL.name, + leftIsManual = + cursor.getNullableString(4) == EvidenceSource.MANUAL.name, + ), + ) + } + } + } + membershipPeriods.forEach { membershipPeriod -> + db.update( + "membership_periods", + ContentValues().apply { + if (membershipPeriod.joinedIsManual && membershipPeriod.joinedAtMillis != null) { + put( + "joined_date", + migrationCalendarDate(membershipPeriod.joinedAtMillis, zoneId).toEpochDay(), + ) + put("joined_time_known", 1) + } + if (membershipPeriod.leftIsManual && membershipPeriod.leftAtMillis != null) { + put( + "left_date", + migrationCalendarDate(membershipPeriod.leftAtMillis, zoneId).toEpochDay(), + ) + put("left_time_known", 1) + } + }, + "id = ?", + arrayOf(membershipPeriod.id.toString()), + ) + } + + val events = db.rawQuery( + """ + SELECT id, occurred_at + FROM member_events + WHERE source = ? + AND occurred_at IS NOT NULL + """.trimIndent(), + arrayOf(EvidenceSource.MANUAL.name), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getLong(0) to cursor.getLong(1)) + } + } + events.forEach { (id, occurredAtMillis) -> + db.update( + "member_events", + ContentValues().apply { + put( + "event_date", + migrationCalendarDate(occurredAtMillis, zoneId).toEpochDay(), + ) + put("time_known", 1) + }, + "id = ?", + arrayOf(id.toString()), + ) + } + } + + private fun backfillSnapshotMembershipPeriodEvents(db: SQLiteDatabase) { + db.rawQuery( + """ + SELECT period.id, period.uid, period.joined_at, period.left_at, + period.joined_source, period.left_source, + COALESCE(m.custom_name, m.current_name) + FROM membership_periods period + JOIN members m ON m.uid = period.uid + WHERE period.joined_source IN (?, ?) + OR period.left_source IN (?, ?) + ORDER BY period.uid, period.id + """.trimIndent(), + arrayOf( + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + ), + ).use { cursor -> + while (cursor.moveToNext()) { + val membershipPeriodId = cursor.getLong(0) + val uid = cursor.getLong(1) + val joinedAt = cursor.getNullableInstant(2) + val leftAt = cursor.getNullableInstant(3) + val joinedSource = EvidenceSource.valueOf(cursor.getString(4)) + val leftSource = cursor.getNullableString(5)?.let(EvidenceSource::valueOf) + val name = cursor.getString(6) + if (joinedAt != null && joinedSource in SNAPSHOT_EVENT_SOURCES) { + MembershipRows.insertSnapshotBoundaryEvent( + db, + membershipPeriodId, + uid, + name, + MembershipRows.joinEventType(db, uid, membershipPeriodId), + joinedAt, + joinedSource, + ) + } + if (leftAt != null && leftSource in SNAPSHOT_EVENT_SOURCES) { + MembershipRows.insertSnapshotBoundaryEvent( + db, + membershipPeriodId, + uid, + name, + MemberEventType.LEFT, + leftAt, + requireNotNull(leftSource), + ) + } + } + } + } + + private data class ManualBoundaryDateBackfill( + val id: Long, + val joinedAtMillis: Long?, + val leftAtMillis: Long?, + val joinedIsManual: Boolean, + val leftIsManual: Boolean, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSchema.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSchema.kt new file mode 100644 index 0000000..1700068 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSchema.kt @@ -0,0 +1,240 @@ +package dev.gf2log.app.management + +import android.database.sqlite.SQLiteDatabase + +/** Owns the current DDL; definitions shared by onCreate and migrations live here once. */ +internal object PlatoonDatabaseSchema { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at INTEGER NOT NULL, + source_file TEXT UNIQUE, + game_version TEXT + ) + """.trimIndent(), + ) + db.execSQL("CREATE INDEX snapshots_captured_at ON snapshots(captured_at DESC)") + db.execSQL( + """ + 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) + ) + """.trimIndent(), + ) + db.execSQL( + """ + 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 '' + ) + """.trimIndent(), + ) + db.execSQL( + """ + CREATE TABLE membership_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid INTEGER NOT NULL REFERENCES members(uid), + joined_at INTEGER, + left_at INTEGER, + joined_date INTEGER, + left_date INTEGER, + joined_time_known INTEGER NOT NULL DEFAULT 1, + left_time_known INTEGER, + joined_precision TEXT NOT NULL, + left_precision TEXT, + joined_source TEXT NOT NULL, + left_source TEXT, + note TEXT NOT NULL DEFAULT '' + ) + """.trimIndent(), + ) + db.execSQL(MEMBERSHIP_PERIODS_UID_INDEX) + db.execSQL(memberEventsTable(withCalendarColumns = true)) + db.execSQL(MEMBER_EVENTS_TIME_INDEX) + createPlatoonActivityTable(db) + db.execSQL(WEEKLY_NOTES_TABLE) + createWeeklyOverridesTable(db) + createPlatoonMaintenanceStateTable(db) + createWeeklyReportHistoryTables(db) + } + + /** + * The member_events definition shared by onCreate and the v10 rebuild. Databases older than + * v9 are rebuilt without the calendar columns, which the v9 step then appends. + */ + fun memberEventsTable(withCalendarColumns: Boolean): String = table( + "member_events", + listOfNotNull( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "uid INTEGER NOT NULL REFERENCES members(uid)", + "membership_period_id INTEGER REFERENCES membership_periods(id)", + "event_type TEXT NOT NULL", + "occurred_at INTEGER", + "event_date INTEGER".takeIf { withCalendarColumns }, + "time_known INTEGER NOT NULL DEFAULT 1".takeIf { withCalendarColumns }, + "observed_at INTEGER NOT NULL", + "precision TEXT NOT NULL", + "source TEXT NOT NULL", + "note TEXT NOT NULL DEFAULT ''", + ), + ) + + val WEEKLY_NOTES_TABLE = table( + "weekly_notes", + listOf( + "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", + ), + ) + + const val MEMBERSHIP_PERIODS_UID_INDEX = + "CREATE INDEX membership_periods_uid ON membership_periods(uid, id DESC)" + const val MEMBER_EVENTS_TIME_INDEX = + "CREATE INDEX member_events_time ON member_events(observed_at DESC)" + + private fun table(name: String, columns: List): String = + "CREATE TABLE $name (\n ${columns.joinToString(",\n ")}\n)" + + /** Idempotent maintenance objects that every writable open guarantees. */ + fun ensureMaintenanceObjects(db: SQLiteDatabase) { + createPlatoonMaintenanceStateTable(db) + createPlatoonActivityRetentionIndex(db) + createWeeklyReportHistoryTables(db) + } + + fun createWeeklyReportHistoryTables(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS weekly_report_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_start INTEGER NOT NULL, + recorded_at INTEGER NOT NULL, + fingerprint TEXT NOT NULL, + report_blob BLOB NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS weekly_report_history_period " + + "ON weekly_report_history(period_start, recorded_at DESC, id DESC)", + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS weekly_report_history_state ( + period_start INTEGER PRIMARY KEY, + active_history_id INTEGER REFERENCES weekly_report_history(id) ON DELETE SET NULL + ) + """.trimIndent(), + ) + } + + fun createWeeklyOverridesTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS 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) + ) + """.trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS weekly_overrides_period " + + "ON weekly_overrides(period_start, game_day)", + ) + } + + fun createPlatoonActivityTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS platoon_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + occurred_at INTEGER NOT NULL, + action_id INTEGER NOT NULL, + kind INTEGER NOT NULL, + member_name TEXT NOT NULL, + captured_at INTEGER NOT NULL, + resolved_uid INTEGER REFERENCES members(uid), + resolution TEXT NOT NULL DEFAULT 'UNRESOLVED', + member_event_id INTEGER REFERENCES member_events(id) + ) + """.trimIndent(), + ) + createPlatoonActivityIndexes(db) + } + + private fun createPlatoonActivityIndexes(db: SQLiteDatabase) { + db.execSQL( + "CREATE INDEX IF NOT EXISTS platoon_activity_member_time " + + "ON platoon_activity(member_name, occurred_at)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS platoon_activity_action_time " + + "ON platoon_activity(action_id, occurred_at)", + ) + db.execSQL( + """ + CREATE UNIQUE INDEX IF NOT EXISTS platoon_activity_exact_identity + ON platoon_activity(occurred_at, action_id, kind, resolved_uid) + WHERE resolved_uid IS NOT NULL + """.trimIndent(), + ) + db.execSQL( + """ + CREATE UNIQUE INDEX IF NOT EXISTS platoon_activity_unresolved_identity + ON platoon_activity(occurred_at, action_id, kind, member_name) + WHERE resolved_uid IS NULL + """.trimIndent(), + ) + createPlatoonActivityRetentionIndex(db) + } + + fun createPlatoonActivityRetentionIndex(db: SQLiteDatabase) { + db.execSQL( + "CREATE INDEX IF NOT EXISTS platoon_activity_resolution_retention " + + "ON platoon_activity(resolved_uid, captured_at DESC, id DESC)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS platoon_activity_retention_order " + + "ON platoon_activity(captured_at DESC, id DESC)", + ) + } + + fun createPlatoonMaintenanceStateTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS platoon_maintenance_state ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + ) + """.trimIndent(), + ) + } +} diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt new file mode 100644 index 0000000..ec15516 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt @@ -0,0 +1,109 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** Snapshot-derived evidence is weak: replay may rebuild it from retained rosters. */ +internal val SNAPSHOT_EVENT_SOURCES = setOf(EvidenceSource.SNAPSHOT, EvidenceSource.LEGACY_IMPORT) + +/** One side of a membership period with its column prefix and the events that record it. */ +internal enum class MembershipBoundary( + val prefix: String, + val eventTypes: List, +) { + JOIN("joined", listOf(MemberEventType.JOINED, MemberEventType.REJOINED)), + WITHDRAW("left", listOf(MemberEventType.LEFT, MemberEventType.REMOVED)), + ; + + val atColumn: String + get() = "${prefix}_at" + + val precisionColumn: String + get() = "${prefix}_precision" + + val sourceColumn: String + get() = "${prefix}_source" + + val opposite: MembershipBoundary + get() = if (this == JOIN) WITHDRAW else JOIN +} + +/** + * Commits only when [block] completes normally. An exception or a non-local `return` rolls the + * transaction back; an early result that must commit returns through `return@inTransaction`. + */ +internal inline fun SQLiteDatabase.inTransaction(block: () -> T): T { + beginTransaction() + try { + val result = block() + setTransactionSuccessful() + return result + } finally { + endTransaction() + } +} + +internal fun SQLiteDatabase.exists(sql: String, vararg arguments: Any): Boolean = + rawQuery(sql, arguments.map(Any::toString).toTypedArray()).use(Cursor::moveToFirst) + +internal fun ContentValues.putNullableLong(key: String, value: Long?) { + if (value == null) putNull(key) else put(key, value) +} + +internal fun ContentValues.putMembershipBoundary( + prefix: String, + boundary: MembershipBoundaryValue, +) { + put("${prefix}_at", boundary.instant.toEpochMilli()) + put("${prefix}_date", boundary.date.toEpochDay()) + put("${prefix}_time_known", if (boundary.timeKnown) 1 else 0) +} + +internal fun ContentValues.clearMembershipBoundary(prefix: String) { + putNull("${prefix}_at") + putNull("${prefix}_date") + putNull("${prefix}_time_known") +} + +internal fun ContentValues.putNullableInt(key: String, value: Int?) { + if (value == null) putNull(key) else put(key, value) +} + +internal fun ContentValues.putNullableBoolean(key: String, value: Boolean?) { + if (value == null) putNull(key) else put(key, if (value) 1 else 0) +} + +internal fun Cursor.getNullableLong(index: Int): Long? = + if (isNull(index)) null else getLong(index) + +internal fun Cursor.getNullableInt(index: Int): Int? = + if (isNull(index)) null else getInt(index) + +internal fun Cursor.getNullableBoolean(index: Int): Boolean? = + if (isNull(index)) null else getInt(index) != 0 + +internal fun Cursor.getNullableString(index: Int): String? = + if (isNull(index)) null else getString(index) + +internal fun Cursor.getNullableInstant(index: Int): Instant? = + getNullableLong(index)?.let(Instant::ofEpochMilli) + +internal fun Cursor.membershipBoundaryValue( + instantIndex: Int, + dateIndex: Int, + timeKnownIndex: Int, + zoneId: ZoneId, +): MembershipBoundaryValue? { + val instant = getNullableInstant(instantIndex) ?: return null + val date = getNullableLong(dateIndex)?.let(LocalDate::ofEpochDay) + ?: instant.atZone(zoneId).toLocalDate() + return MembershipBoundaryValue( + date = date, + instant = instant, + timeKnown = getNullableInt(timeKnownIndex)?.let { it != 0 } ?: true, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt new file mode 100644 index 0000000..b7a55b8 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt @@ -0,0 +1,268 @@ +package dev.gf2log.app.management + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.PlatoonDatabase.Companion.DAILY_PATROL_REWARD_ACTION_ID +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** Read-only projections served to the repository. */ +internal object PlatoonReadQueries { + fun latestSnapshotIdentity(db: SQLiteDatabase): Pair? = db.rawQuery( + "SELECT captured_at, source_file FROM snapshots " + + "ORDER BY captured_at DESC, source_file DESC, id DESC LIMIT 1", + null, + ).use { cursor -> + if (cursor.moveToFirst()) { + Instant.ofEpochMilli(cursor.getLong(0)) to cursor.getNullableString(1) + } else { + null + } + } + + fun snapshotSourceFiles(db: SQLiteDatabase): Set = db.rawQuery( + "SELECT source_file FROM snapshots WHERE source_file IS NOT NULL", + null, + ).use { cursor -> + buildSet { + while (cursor.moveToNext()) add(cursor.getString(0)) + } + } + + fun listDailyPatrolFacts(db: SQLiteDatabase, from: Instant, until: Instant): List { + require(!until.isBefore(from)) + return db.query( + true, + "platoon_activity", + arrayOf("resolved_uid", "occurred_at"), + """ + action_id = ? AND resolved_uid IS NOT NULL + AND resolution = ? AND occurred_at >= ? AND occurred_at < ? + """.trimIndent(), + arrayOf( + DAILY_PATROL_REWARD_ACTION_ID.toString(), + ActivityResolution.EXACT_UPDATE.name, + from.toEpochMilli().toString(), + until.toEpochMilli().toString(), + ), + null, + null, + "occurred_at, resolved_uid", + null, + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + DailyPatrolFact( + uid = cursor.getLong(0), + occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), + ), + ) + } + } + } + } + + fun listMemberStatuses(db: SQLiteDatabase, activeOnly: Boolean): List { + val selection = if (activeOnly) "is_active = 1" else null + return db.query( + "members", + MEMBER_COLUMNS, + selection, + null, + null, + null, + "is_active DESC, COALESCE(custom_name, current_name) COLLATE NOCASE, uid", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + val uid = cursor.getLong(0) + add( + MemberStatus( + uid = uid, + name = cursor.getString(1), + level = cursor.getLong(2), + isActive = cursor.getInt(3) != 0, + firstSeenAt = Instant.ofEpochMilli(cursor.getLong(4)), + lastSeenAt = Instant.ofEpochMilli(cursor.getLong(5)), + note = cursor.getString(6), + membershipPeriods = readMembershipPeriods(db, uid), + ), + ) + } + } + } + } + + private fun readMembershipPeriods(db: SQLiteDatabase, uid: Long): List = + db.query( + "membership_periods", + MEMBERSHIP_PERIOD_COLUMNS, + "uid = ?", + arrayOf(uid.toString()), + null, + null, + "CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, joined_at, id", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + MembershipPeriod( + id = cursor.getLong(0), + uid = uid, + joinedAt = cursor.getNullableInstant(1), + leftAt = cursor.getNullableInstant(2), + joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), + leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), + joinedTimeKnown = cursor.getInt(5) != 0, + leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 }, + joinedPrecision = enumValueOf(cursor.getString(7)), + leftPrecision = cursor.getNullableString(8) + ?.let(EvidencePrecision::valueOf), + joinedSource = enumValueOf(cursor.getString(9)), + leftSource = cursor.getNullableString(10) + ?.let(EvidenceSource::valueOf), + note = cursor.getString(11), + ), + ) + } + } + } + + fun listEvents( + db: SQLiteDatabase, + from: Instant, + until: Instant, + fromDate: LocalDate, + untilDate: LocalDate, + ): List { + require(!until.isBefore(from)) + require(!untilDate.isBefore(fromDate)) + return db.query( + "member_events", + EVENT_COLUMNS, + """ + ( + event_date IS NOT NULL + AND event_date >= CAST(? AS INTEGER) + AND event_date < CAST(? AS INTEGER) + ) + OR + ( + event_date IS NULL + AND COALESCE(occurred_at, observed_at) >= CAST(? AS INTEGER) + AND COALESCE(occurred_at, observed_at) < CAST(? AS INTEGER) + ) + """.trimIndent(), + arrayOf( + fromDate.toEpochDay().toString(), + untilDate.toEpochDay().toString(), + from.toEpochMilli().toString(), + until.toEpochMilli().toString(), + ), + null, + null, + "COALESCE(occurred_at, observed_at), id", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.toMemberEvent()) + } + } + } + + fun listWeeklyEvidenceDays(db: SQLiteDatabase, zoneId: ZoneId): List { + val days = mutableListOf() + listOf( + "snapshots" to "captured_at", + "platoon_activity" to "occurred_at", + ).forEach { (table, expression) -> + db.rawQuery("SELECT DISTINCT $expression FROM $table", null).use { cursor -> + while (cursor.moveToNext()) { + if (!cursor.isNull(0)) { + days += PlatoonPeriods.gameDay(Instant.ofEpochMilli(cursor.getLong(0)), zoneId) + } + } + } + } + db.rawQuery( + "SELECT DISTINCT COALESCE(occurred_at, observed_at) " + + "FROM member_events WHERE event_date IS NULL", + null, + ).use { cursor -> + while (cursor.moveToNext()) { + if (!cursor.isNull(0)) { + days += PlatoonPeriods.gameDay(Instant.ofEpochMilli(cursor.getLong(0)), zoneId) + } + } + } + listOf("weekly_notes", "weekly_overrides").forEach { table -> + db.rawQuery("SELECT DISTINCT period_start FROM $table", null).use { cursor -> + while (cursor.moveToNext()) { + if (!cursor.isNull(0)) days += LocalDate.ofEpochDay(cursor.getLong(0)) + } + } + } + db.rawQuery("SELECT DISTINCT event_date FROM member_events", null).use { cursor -> + while (cursor.moveToNext()) { + if (!cursor.isNull(0)) days += LocalDate.ofEpochDay(cursor.getLong(0)) + } + } + return days.distinct() + } + + fun count(db: SQLiteDatabase, table: String): Long = + db.rawQuery("SELECT COUNT(*) FROM $table", null).use { cursor -> + cursor.moveToFirst() + cursor.getLong(0) + } + + private fun Cursor.toMemberEvent(): MemberEvent = MemberEvent( + id = getLong(0), + uid = getLong(1), + type = enumValueOf(getString(2)), + occurredAt = getNullableInstant(3), + eventDate = getNullableLong(4)?.let(LocalDate::ofEpochDay), + timeKnown = getInt(5) != 0, + observedAt = Instant.ofEpochMilli(getLong(6)), + precision = enumValueOf(getString(7)), + source = enumValueOf(getString(8)), + note = getString(9), + ) + + private val MEMBER_COLUMNS = arrayOf( + "uid", + "COALESCE(custom_name, current_name)", + "current_level", + "is_active", + "first_seen_at", + "last_seen_at", + "note", + ) + private val EVENT_COLUMNS = arrayOf( + "id", + "uid", + "event_type", + "occurred_at", + "event_date", + "time_known", + "observed_at", + "precision", + "source", + "note", + ) + private val MEMBERSHIP_PERIOD_COLUMNS = arrayOf( + "id", + "joined_at", + "left_at", + "joined_date", + "left_date", + "joined_time_known", + "left_time_known", + "joined_precision", + "left_precision", + "joined_source", + "left_source", + "note", + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt b/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt new file mode 100644 index 0000000..84a8b24 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt @@ -0,0 +1,446 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.MembershipRows.insertSnapshotBoundaryEvent +import dev.gf2log.app.management.MembershipRows.joinEventType +import dev.gf2log.app.management.MembershipRows.memberExists +import java.time.Instant + +/** + * Replays every retained roster chronologically after late historical imports. Only weak + * snapshot-derived periods are rebuilt; manual and exact evidence is preserved, and people seen + * only in historical rosters become inactive members. + */ +internal object RosterHistoryReplay { + fun reconcile(db: SQLiteDatabase) { + val snapshots = readRosterFrames(db) + if (snapshots.isEmpty()) return + + ensureHistoricalMembers(db, snapshots) + removeWeakSnapshotMembershipPeriods(db) + + val allUids = snapshots.flatMap { frame -> frame.members.keys }.toSortedSet() + allUids.forEach { uid -> + val runs = buildRosterPresenceRuns(snapshots, uid) + runs.forEach { run -> + if (!reconcilePreservedMembershipPeriodOverlap(db, uid, run)) { + insertRosterPresenceRun(db, uid, run) + } + } + } + ActivityIngestion.resolveUnresolvedActivityUids(db) + } + + private fun readRosterFrames(db: SQLiteDatabase): List { + val frames = linkedMapOf>() + db.rawQuery( + """ + SELECT s.id, s.captured_at, sm.uid, sm.name, sm.level + FROM snapshots s + JOIN snapshot_members sm ON sm.snapshot_id = s.id + ORDER BY s.captured_at, s.id, sm.uid + """.trimIndent(), + null, + ).use { cursor -> + while (cursor.moveToNext()) { + val snapshotId = cursor.getLong(0) + val capturedAt = Instant.ofEpochMilli(cursor.getLong(1)) + frames.getOrPut(snapshotId) { linkedMapOf() }[cursor.getLong(2)] = + RosterIdentity(cursor.getString(3), cursor.getInt(4), capturedAt) + } + } + return frames.map { (_, members) -> + RosterFrame(members.values.first().capturedAt, members) + } + } + + private fun ensureHistoricalMembers(db: SQLiteDatabase, snapshots: List) { + val latestFrame = snapshots.last() + snapshots.flatMap { frame -> + frame.members.map { (uid, identity) -> Triple(uid, identity, frame.capturedAt) } + }.groupBy { it.first }.forEach { (uid, observations) -> + val firstSeenAt = observations.minOf { it.third } + val latest = observations.maxBy { it.third } + if (!memberExists(db, uid)) { + MembershipRows.insertMember( + db = db, + uid = uid, + name = latest.second.name, + level = latest.second.level.toLong(), + isActive = uid in latestFrame.members, + firstSeenAt = firstSeenAt, + lastSeenAt = latest.third, + ) + } else { + db.execSQL( + "UPDATE members SET first_seen_at = MIN(first_seen_at, ?) WHERE uid = ?", + arrayOf(firstSeenAt.toEpochMilli(), uid), + ) + } + } + } + + private fun removeWeakSnapshotMembershipPeriods(db: SQLiteDatabase) { + val sourceNames = SNAPSHOT_EVENT_SOURCES.map(EvidenceSource::name) + val weakIds = db.rawQuery( + """ + SELECT id + FROM membership_periods + WHERE joined_source IN (?, ?) + AND (left_source IS NULL OR left_source IN (?, ?)) + AND joined_precision IN (?, ?) + AND (left_precision IS NULL OR left_precision = ?) + AND note = '' + """.trimIndent(), + arrayOf( + sourceNames[0], + sourceNames[1], + sourceNames[0], + sourceNames[1], + EvidencePrecision.UNKNOWN.name, + EvidencePrecision.INFERRED.name, + EvidencePrecision.INFERRED.name, + ), + ).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getLong(0)) } } + + weakIds.forEach { membershipPeriodId -> + MembershipRows.deleteMembershipPeriod(db, membershipPeriodId) + } + } + + private fun buildRosterPresenceRuns( + snapshots: List, + uid: Long, + ): List { + val runs = mutableListOf() + var index = 0 + while (index < snapshots.size) { + if (uid !in snapshots[index].members) { + index += 1 + continue + } + val firstIndex = index + while (index + 1 < snapshots.size && uid in snapshots[index + 1].members) index += 1 + val lastIndex = index + val first = snapshots[firstIndex] + val last = snapshots[lastIndex] + runs += RosterPresenceRun( + firstSeenAt = first.capturedAt, + lastSeenAt = last.capturedAt, + joinedAt = first.capturedAt.takeIf { firstIndex > 0 }, + leftAt = snapshots.getOrNull(lastIndex + 1)?.capturedAt, + name = first.members.getValue(uid).name, + ) + index += 1 + } + return runs + } + + /** + * Preserves exact, manual, or user-annotated periods overlapping one roster-presence run. + * Every overlapping open period closes at an observed absence, so a later run becomes a + * separate inferred rejoin. Returns false when no preserved period covers the run. + */ + private fun reconcilePreservedMembershipPeriodOverlap( + db: SQLiteDatabase, + uid: Long, + run: RosterPresenceRun, + ): Boolean { + val overlaps = db.rawQuery( + """ + SELECT id, joined_at, joined_precision, joined_source, + left_at, left_precision, left_source + FROM membership_periods + WHERE uid = ? + AND (joined_at IS NULL OR joined_at <= ?) + AND (left_at IS NULL OR left_at > ?) + ORDER BY COALESCE(joined_at, -9223372036854775808), id + """.trimIndent(), + arrayOf( + uid.toString(), + run.lastSeenAt.toEpochMilli().toString(), + run.firstSeenAt.toEpochMilli().toString(), + ), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + PreservedMembershipPeriodOverlap( + id = cursor.getLong(0), + joinedAt = cursor.getNullableInstant(1), + joinedPrecision = EvidencePrecision.valueOf(cursor.getString(2)), + joinedSource = EvidenceSource.valueOf(cursor.getString(3)), + leftAt = cursor.getNullableInstant(4), + leftPrecision = cursor.getNullableString(5) + ?.let(EvidencePrecision::valueOf), + leftSource = cursor.getNullableString(6) + ?.let(EvidenceSource::valueOf), + ), + ) + } + } + } + overlaps.forEach { membershipPeriod -> + expandWeakJoinToRun(db, uid, membershipPeriod, run) + reconcileRunAbsence(db, uid, membershipPeriod, run) + } + return overlaps.isNotEmpty() + } + + /** Expands a weak inferred join to the earliest roster presence discovered later. */ + private fun expandWeakJoinToRun( + db: SQLiteDatabase, + uid: Long, + membershipPeriod: PreservedMembershipPeriodOverlap, + run: RosterPresenceRun, + ) { + val joinedAt = membershipPeriod.joinedAt + if ( + joinedAt != null && + run.firstSeenAt.isBefore(joinedAt) && + isWeakSnapshotBoundary( + membershipPeriod.joinedPrecision, + membershipPeriod.joinedSource, + ) + ) { + val updated = db.update( + "membership_periods", + ContentValues().apply { + put("joined_at", run.firstSeenAt.toEpochMilli()) + putNull("joined_date") + put("joined_time_known", 1) + put("joined_precision", EvidencePrecision.INFERRED.name) + put("joined_source", EvidenceSource.LEGACY_IMPORT.name) + }, + "id = ?", + arrayOf(membershipPeriod.id.toString()), + ) + if (updated == 1) { + updateSnapshotBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriod.id, + uid = uid, + memberName = run.name, + type = joinEventType(db, uid, membershipPeriod.id), + observedAt = run.firstSeenAt, + ) + } + } + } + + /** Closes a preserved period at an observed roster absence, or reopens a weak closure. */ + private fun reconcileRunAbsence( + db: SQLiteDatabase, + uid: Long, + membershipPeriod: PreservedMembershipPeriodOverlap, + run: RosterPresenceRun, + ) { + val observedLeftAt = run.leftAt + val weakLeftBoundary = membershipPeriod.leftPrecision != null && + membershipPeriod.leftSource != null && + isWeakSnapshotBoundary( + membershipPeriod.leftPrecision, + membershipPeriod.leftSource, + ) + if ( + observedLeftAt != null && + ( + membershipPeriod.leftAt == null || + (weakLeftBoundary && membershipPeriod.leftAt != observedLeftAt) + ) + ) { + val updated = db.update( + "membership_periods", + ContentValues().apply { + put("left_at", observedLeftAt.toEpochMilli()) + putNull("left_date") + put("left_time_known", 1) + put("left_precision", EvidencePrecision.INFERRED.name) + put("left_source", EvidenceSource.LEGACY_IMPORT.name) + }, + "id = ?", + arrayOf(membershipPeriod.id.toString()), + ) + if (updated == 1) { + updateSnapshotBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriod.id, + uid = uid, + memberName = run.name, + type = MemberEventType.LEFT, + observedAt = observedLeftAt, + ) + } + } else if ( + observedLeftAt == null && + membershipPeriod.leftAt != null && + weakLeftBoundary && + !run.lastSeenAt.isBefore(membershipPeriod.leftAt) + ) { + db.update( + "membership_periods", + ContentValues().apply { + putNull("left_at") + putNull("left_date") + putNull("left_time_known") + putNull("left_precision") + putNull("left_source") + }, + "id = ?", + arrayOf(membershipPeriod.id.toString()), + ) + deleteSnapshotBoundaryEvents( + db = db, + membershipPeriodId = membershipPeriod.id, + type = MemberEventType.LEFT, + ) + } + } + + private fun isWeakSnapshotBoundary( + precision: EvidencePrecision, + source: EvidenceSource, + ): Boolean = precision in setOf(EvidencePrecision.UNKNOWN, EvidencePrecision.INFERRED) && + source in SNAPSHOT_EVENT_SOURCES + + private fun updateSnapshotBoundaryEvent( + db: SQLiteDatabase, + membershipPeriodId: Long, + uid: Long, + memberName: String, + type: MemberEventType, + observedAt: Instant, + ) { + val compatibleTypes = MembershipRows.compatibleEventTypes(type) + val updated = db.update( + "member_events", + ContentValues().apply { + put("event_type", type.name) + putNull("occurred_at") + putNull("event_date") + put("time_known", 1) + put("observed_at", observedAt.toEpochMilli()) + put("precision", EvidencePrecision.INFERRED.name) + put("source", EvidenceSource.LEGACY_IMPORT.name) + put("note", memberName) + }, + "membership_period_id = ? AND event_type IN (?, ?) " + + "AND source IN (?, ?)", + arrayOf( + membershipPeriodId.toString(), + compatibleTypes[0], + compatibleTypes[1], + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + ), + ) + if (updated == 0) { + insertSnapshotBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriodId, + uid = uid, + memberName = memberName, + type = type, + observedAt = observedAt, + source = EvidenceSource.LEGACY_IMPORT, + ) + } + } + + private fun deleteSnapshotBoundaryEvents( + db: SQLiteDatabase, + membershipPeriodId: Long, + type: MemberEventType, + ) { + MembershipRows.deleteEvents( + db, + "membership_period_id = ? AND event_type = ? AND source IN (?, ?)", + arrayOf( + membershipPeriodId.toString(), + type.name, + EvidenceSource.SNAPSHOT.name, + EvidenceSource.LEGACY_IMPORT.name, + ), + ) + } + + private fun insertRosterPresenceRun( + db: SQLiteDatabase, + uid: Long, + run: RosterPresenceRun, + ) { + val membershipPeriodId = db.insertOrThrow( + "membership_periods", + null, + ContentValues().apply { + put("uid", uid) + putNullableLong("joined_at", run.joinedAt?.toEpochMilli()) + putNullableLong("left_at", run.leftAt?.toEpochMilli()) + put("joined_time_known", 1) + putNullableInt("left_time_known", run.leftAt?.let { 1 }) + put( + "joined_precision", + if (run.joinedAt == null) EvidencePrecision.UNKNOWN.name else EvidencePrecision.INFERRED.name, + ) + put( + "left_precision", + run.leftAt?.let { EvidencePrecision.INFERRED.name }, + ) + put("joined_source", EvidenceSource.LEGACY_IMPORT.name) + put("left_source", run.leftAt?.let { EvidenceSource.LEGACY_IMPORT.name }) + }, + ) + if (run.joinedAt != null) { + insertSnapshotBoundaryEvent( + db, + membershipPeriodId, + uid, + run.name, + joinEventType(db, uid, membershipPeriodId), + run.joinedAt, + EvidenceSource.LEGACY_IMPORT, + ) + } + if (run.leftAt != null) { + insertSnapshotBoundaryEvent( + db, + membershipPeriodId, + uid, + run.name, + MemberEventType.LEFT, + run.leftAt, + EvidenceSource.LEGACY_IMPORT, + ) + } + } + + private data class RosterIdentity( + val name: String, + val level: Int, + val capturedAt: Instant, + ) + + private data class RosterFrame( + val capturedAt: Instant, + val members: Map, + ) + + private data class PreservedMembershipPeriodOverlap( + val id: Long, + val joinedAt: Instant?, + val joinedPrecision: EvidencePrecision, + val joinedSource: EvidenceSource, + val leftAt: Instant?, + val leftPrecision: EvidencePrecision?, + val leftSource: EvidenceSource?, + ) + + private data class RosterPresenceRun( + val firstSeenAt: Instant, + val lastSeenAt: Instant, + val joinedAt: Instant?, + val leftAt: Instant?, + val name: String, + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt new file mode 100644 index 0000000..2cf4b48 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt @@ -0,0 +1,350 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.ActivityIngestion.correlateMembershipBoundary +import dev.gf2log.app.management.ExactBoundaryCorrelation.findRosterConfirmedExactMembershipPeriod +import dev.gf2log.app.management.MembershipRows.closeLatestMembershipPeriod +import dev.gf2log.app.management.MembershipRows.ensureRosterActiveMembershipPeriod +import dev.gf2log.app.management.MembershipRows.insertEvent +import dev.gf2log.app.management.MembershipRows.insertMembershipPeriod +import dev.gf2log.app.management.MembershipRows.insertSnapshotBoundaryEvent +import dev.gf2log.protocol.GuildMembersCsv +import java.time.Instant +import java.util.Locale + +/** Ingests one 21917 roster snapshot and derives roster-change membership boundaries. */ +internal object SnapshotIngestion { + fun ingest( + db: SQLiteDatabase, + snapshot: PlatoonSnapshot, + source: EvidenceSource, + historicalOnly: Boolean, + deferHistoricalMembershipReconciliation: Boolean, + ): SnapshotIngestResult { + require(snapshot.members.isNotEmpty()) { "A Platoon snapshot cannot be empty" } + require(snapshot.members.size <= GuildMembersCsv.MAX_ROSTER_MEMBERS) { + "A Platoon snapshot exceeds the member limit" + } + require(snapshot.members.all { it.name.length <= GuildMembersCsv.MAX_MEMBER_NAME_CHARS }) { + "A Platoon snapshot contains an oversized member name" + } + require(snapshot.members.map(SnapshotMember::uid).distinct().size == snapshot.members.size) { + "A Platoon snapshot cannot contain duplicate UIDs" + } + return db.inTransaction { + if (snapshot.sourceFile != null && sourceFileExists(db, snapshot.sourceFile)) { + return SnapshotIngestResult.duplicate() + } + val priorCapturedAt = latestSnapshotCapturedAt(db) + if (historicalOnly) { + require(priorCapturedAt != null && !snapshot.capturedAt.isAfter(priorCapturedAt)) { + "Historical snapshots must not be newer than current structured data" + } + val snapshotId = insertSnapshotRows(db, snapshot) + if (!deferHistoricalMembershipReconciliation) RosterHistoryReplay.reconcile(db) + SnapshotIngestResult.historical(snapshotId) + } else { + ingestCurrent(db, snapshot, source, priorCapturedAt) + } + } + } + + private fun ingestCurrent( + db: SQLiteDatabase, + snapshot: PlatoonSnapshot, + source: EvidenceSource, + priorCapturedAt: Instant?, + ): SnapshotIngestResult { + val known = readKnownMembers(db) + val changes = SnapshotReconciler.reconcile( + known = known, + incoming = snapshot.members, + hasPriorSnapshot = priorCapturedAt != null, + ) + val snapshotId = insertSnapshotRows(db, snapshot) + snapshot.members.forEach { member -> upsertMember(db, member, snapshot.capturedAt) } + if (priorCapturedAt != null) { + recordRosterChanges(db, snapshot, source, priorCapturedAt, known, changes) + } + // A roster is authoritative for current presence. Repair any missing open + // membership period left by an incomplete historical Updates feed. + snapshot.members.forEach { member -> + ensureRosterActiveMembershipPeriod(db, member.uid, source) + } + ActivityIngestion.trimPlatoonActivity(db) + ActivityIngestion.resolveUnresolvedActivityUids(db) + return SnapshotIngestResult( + snapshotId = snapshotId, + duplicate = false, + initialRoster = changes.initialRoster, + joined = changes.joined.size, + rejoined = changes.rejoined.size, + left = changes.left.size, + renamed = changes.renamed.size, + ) + } + + private fun recordRosterChanges( + db: SQLiteDatabase, + snapshot: PlatoonSnapshot, + source: EvidenceSource, + priorCapturedAt: Instant, + known: List, + changes: SnapshotReconciler.Result, + ) { + val changedNameCounts = ( + changes.joined.map(SnapshotMember::name) + + changes.rejoined.map(SnapshotMember::name) + + changes.left.map(SnapshotReconciler.KnownMember::name) + ).groupingBy { it.lowercase(Locale.ROOT) }.eachCount() + val rosterNameUidCounts = ( + known.map { it.uid to it.name } + + snapshot.members.map { it.uid to it.name } + ).groupBy { it.second.lowercase(Locale.ROOT) } + .mapValues { (_, identities) -> identities.map { it.first }.distinct().size } + + // Activity names can be correlated only when neither the change set nor the roster + // contains another identity with the same case-insensitive name. + fun nameIsUnique(name: String): Boolean = name.lowercase(Locale.ROOT).let { key -> + changedNameCounts[key] == 1 && rosterNameUidCounts[key] == 1 + } + + changes.joined.forEach { member -> + recordSnapshotJoin( + db = db, + member = member, + type = MemberEventType.JOINED, + observedAt = snapshot.capturedAt, + priorCapturedAt = priorCapturedAt, + source = source, + nameIsUnique = nameIsUnique(member.name), + ) + } + changes.rejoined.forEach { member -> + recordSnapshotJoin( + db = db, + member = member, + type = MemberEventType.REJOINED, + observedAt = snapshot.capturedAt, + priorCapturedAt = priorCapturedAt, + source = source, + nameIsUnique = nameIsUnique(member.name), + ) + } + changes.left.forEach { member -> + recordSnapshotLeave( + db = db, + member = member, + observedAt = snapshot.capturedAt, + priorCapturedAt = priorCapturedAt, + source = source, + nameIsUnique = nameIsUnique(member.name), + ) + } + changes.renamed.forEach { rename -> + insertEvent( + db = db, + uid = rename.uid, + type = MemberEventType.RENAMED, + occurredAt = snapshot.capturedAt, + observedAt = snapshot.capturedAt, + precision = EvidencePrecision.INFERRED, + source = source, + note = "${rename.oldName} -> ${rename.newName}", + ) + } + } + + private fun recordSnapshotJoin( + db: SQLiteDatabase, + member: SnapshotMember, + type: MemberEventType, + observedAt: Instant, + priorCapturedAt: Instant, + source: EvidenceSource, + nameIsUnique: Boolean, + ) { + val exactMembershipPeriodId = findRosterConfirmedExactMembershipPeriod( + db = db, + uid = member.uid, + boundary = MembershipBoundary.JOIN, + from = priorCapturedAt, + observedAt = observedAt, + ) + if (exactMembershipPeriodId != null) return + + val membershipPeriodId = insertMembershipPeriod( + db = db, + uid = member.uid, + joinedAt = observedAt, + precision = EvidencePrecision.INFERRED, + source = source, + ) + insertSnapshotBoundaryEvent( + db = db, + membershipPeriodId = membershipPeriodId, + uid = member.uid, + memberName = member.name, + type = type, + observedAt = observedAt, + source = source, + ) + correlateMembershipBoundary( + db = db, + membershipPeriodId = membershipPeriodId, + uid = member.uid, + memberName = member.name, + type = type, + boundary = MembershipBoundary.JOIN, + observedAt = observedAt, + from = priorCapturedAt, + nameIsUnique = nameIsUnique, + ) + } + + private fun recordSnapshotLeave( + db: SQLiteDatabase, + member: SnapshotReconciler.KnownMember, + observedAt: Instant, + priorCapturedAt: Instant, + source: EvidenceSource, + nameIsUnique: Boolean, + ) { + val exactMembershipPeriodId = findRosterConfirmedExactMembershipPeriod( + db = db, + uid = member.uid, + boundary = MembershipBoundary.WITHDRAW, + from = priorCapturedAt, + observedAt = observedAt, + ) + val membershipPeriodId = exactMembershipPeriodId ?: closeLatestMembershipPeriod( + db = db, + uid = member.uid, + leftAt = observedAt, + precision = EvidencePrecision.INFERRED, + source = source, + ) + markInactive(db, member.uid, observedAt) + if (exactMembershipPeriodId != null) return + insertSnapshotBoundaryEvent( + db, + membershipPeriodId, + member.uid, + member.name, + MemberEventType.LEFT, + observedAt, + source, + ) + correlateMembershipBoundary( + db = db, + membershipPeriodId = membershipPeriodId, + uid = member.uid, + memberName = member.name, + type = MemberEventType.LEFT, + boundary = MembershipBoundary.WITHDRAW, + observedAt = observedAt, + from = priorCapturedAt, + nameIsUnique = nameIsUnique, + ) + } + + fun insertSnapshotRows(db: SQLiteDatabase, snapshot: PlatoonSnapshot): Long { + val snapshotId = db.insertOrThrow( + "snapshots", + null, + ContentValues().apply { + put("captured_at", snapshot.capturedAt.toEpochMilli()) + put("source_file", snapshot.sourceFile) + put("game_version", snapshot.gameVersion) + }, + ) + snapshot.members.forEach { member -> insertSnapshotMember(db, snapshotId, member) } + return snapshotId + } + + private fun insertSnapshotMember( + db: SQLiteDatabase, + snapshotId: Long, + member: SnapshotMember, + ) { + db.insertOrThrow( + "snapshot_members", + null, + ContentValues().apply { + put("snapshot_id", snapshotId) + put("uid", member.uid) + put("name", member.name) + put("level", member.level) + put("weekly_merit", member.weeklyMerit) + put("total_merit", member.totalMerit) + put("high_score", member.highScore) + put("total_score", member.totalScore) + put("last_login", member.lastLogin) + }, + ) + } + + private fun upsertMember(db: SQLiteDatabase, member: SnapshotMember, observedAt: Instant) { + val existing = ContentValues().apply { + put("current_name", member.name) + put("current_level", member.level) + put("is_active", 1) + put("last_seen_at", observedAt.toEpochMilli()) + } + if (db.update("members", existing, "uid = ?", arrayOf(member.uid.toString())) == 0) { + MembershipRows.insertMember( + db = db, + uid = member.uid, + name = member.name, + level = member.level, + isActive = true, + firstSeenAt = observedAt, + lastSeenAt = observedAt, + ) + } + } + + private fun markInactive(db: SQLiteDatabase, uid: Long, observedAt: Instant) { + db.update( + "members", + ContentValues().apply { + put("is_active", 0) + put("last_seen_at", observedAt.toEpochMilli()) + }, + "uid = ?", + arrayOf(uid.toString()), + ) + } + + private fun readKnownMembers(db: SQLiteDatabase): List = + db.rawQuery( + """ + SELECT m.uid, m.current_name, m.is_active, + EXISTS(SELECT 1 FROM membership_periods period WHERE period.uid = m.uid) + FROM members m + """.trimIndent(), + null, + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + SnapshotReconciler.KnownMember( + uid = cursor.getLong(0), + name = cursor.getString(1), + isActive = cursor.getInt(2) != 0, + hasPriorMembershipPeriod = cursor.getInt(3) != 0, + ), + ) + } + } + } + + private fun sourceFileExists(db: SQLiteDatabase, sourceFile: String): Boolean = + db.exists("SELECT 1 FROM snapshots WHERE source_file = ? LIMIT 1", sourceFile) + + fun latestSnapshotCapturedAt(db: SQLiteDatabase): Instant? = + db.rawQuery("SELECT MAX(captured_at) FROM snapshots", null).use { cursor -> + if (cursor.moveToFirst()) cursor.getNullableInstant(0) else null + } +} diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt new file mode 100644 index 0000000..192f116 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt @@ -0,0 +1,160 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import java.time.LocalDate + +/** Weekly notes and manual cell overrides. */ +internal object WeeklyAnnotationStore { + fun addWeeklyNote( + db: SQLiteDatabase, + periodStartEpochDay: Long, + gameDayEpochDay: Long, + text: String, + ): Long { + require(text.isNotBlank()) + val manualNoteCount = db.rawQuery( + "SELECT COUNT(*) FROM weekly_notes WHERE period_start = ? AND is_automatic = 0", + arrayOf(periodStartEpochDay.toString()), + ).use { cursor -> + check(cursor.moveToFirst()) + cursor.getInt(0) + } + if (!WeeklyNotePolicy.canAdd(manualNoteCount)) throw WeeklyNoteLimitException() + return db.insertOrThrow( + "weekly_notes", + null, + ContentValues().apply { + put("period_start", periodStartEpochDay) + put("game_day", gameDayEpochDay) + put("text", text.trim()) + putNull("event_id") + put("is_automatic", 0) + }, + ) + } + + fun listWeeklyNotes(db: SQLiteDatabase, periodStartEpochDay: Long): List = + db.query( + "weekly_notes", + WEEKLY_NOTE_COLUMNS, + "period_start = ?", + arrayOf(periodStartEpochDay.toString()), + null, + null, + "game_day, id", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + WeeklyNote( + id = cursor.getLong(0), + periodStart = LocalDate.ofEpochDay(cursor.getLong(1)), + gameDay = LocalDate.ofEpochDay(cursor.getLong(2)), + text = cursor.getString(3), + eventId = cursor.getNullableLong(4), + isAutomatic = cursor.getInt(5) != 0, + ), + ) + } + } + } + + fun weeklyNotePeriodStart(db: SQLiteDatabase, id: Long): LocalDate? = db.rawQuery( + "SELECT period_start FROM weekly_notes WHERE id = ? AND is_automatic = 0", + arrayOf(id.toString()), + ).use { cursor -> + if (cursor.moveToFirst()) LocalDate.ofEpochDay(cursor.getLong(0)) else null + } + + fun deleteWeeklyNote(db: SQLiteDatabase, id: Long): Boolean = + db.delete( + "weekly_notes", + "id = ? AND is_automatic = 0", + arrayOf(id.toString()), + ) == 1 + + fun listWeeklyOverrides(db: SQLiteDatabase, periodStartEpochDay: Long): List = + db.query( + "weekly_overrides", + WEEKLY_OVERRIDE_COLUMNS, + "period_start = ?", + arrayOf(periodStartEpochDay.toString()), + null, + null, + "game_day, uid", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + WeeklyCellOverride( + uid = cursor.getLong(0), + periodStart = LocalDate.ofEpochDay(cursor.getLong(1)), + gameDay = LocalDate.ofEpochDay(cursor.getLong(2)), + meritDelta = cursor.getNullableLong(3), + scoreDelta = cursor.getNullableLong(4), + attempts = cursor.getNullableInt(5), + attended = cursor.getNullableBoolean(6), + dailyPatrol = cursor.getNullableBoolean(7), + ), + ) + } + } + } + + fun replaceWeeklyOverrides( + db: SQLiteDatabase, + periodStartEpochDay: Long, + overrides: List, + ) { + require(overrides.all { it.periodStart.toEpochDay() == periodStartEpochDay }) + require( + overrides.all { + it.attempts == null || it.attempts in 0..ActivityInference.MAX_DAILY_ATTEMPTS + }, + ) + require(overrides.none { it.attended == false && it.dailyPatrol == true }) + db.inTransaction { + db.delete( + "weekly_overrides", + "period_start = ?", + arrayOf(periodStartEpochDay.toString()), + ) + overrides.forEach { override -> + db.insertOrThrow( + "weekly_overrides", + null, + ContentValues().apply { + put("uid", override.uid) + put("period_start", periodStartEpochDay) + put("game_day", override.gameDay.toEpochDay()) + putNullableLong("merit_delta", override.meritDelta) + putNullableLong("score_delta", override.scoreDelta) + putNullableInt("attempts", override.attempts) + putNullableBoolean("attended", override.attended) + putNullableBoolean("daily_patrol", override.dailyPatrol) + }, + ) + } + } + } + + private val WEEKLY_NOTE_COLUMNS = arrayOf( + "id", + "period_start", + "game_day", + "text", + "event_id", + "is_automatic", + ) + private val WEEKLY_OVERRIDE_COLUMNS = arrayOf( + "uid", + "period_start", + "game_day", + "merit_delta", + "score_delta", + "attempts", + "attended", + "daily_patrol", + ) +} diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt new file mode 100644 index 0000000..037a170 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt @@ -0,0 +1,184 @@ +package dev.gf2log.app.management + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import dev.gf2log.app.management.PlatoonDatabase.Companion.MAX_WEEKLY_REPORT_HISTORY +import java.time.Instant +import java.time.LocalDate + +internal data class WeeklyReportHistoryReplacement( + val periodStartEpochDay: Long, + val recordedAt: Instant, + val fingerprint: String, + val payload: ByteArray, +) + +/** Derived weekly-table revisions and the restored-selection state. */ +internal object WeeklyHistoryStore { + /** + * Appends one changed immutable weekly projection and retains the newest + * [MAX_WEEKLY_REPORT_HISTORY] revisions. A restored selection is cleared only when + * [clearActiveOnChange] and the live fingerprint changed. Returns the existing or new ID. + */ + fun record( + db: SQLiteDatabase, + periodStartEpochDay: Long, + recordedAt: Instant, + fingerprint: String, + payload: ByteArray, + clearActiveOnChange: Boolean, + ): Long { + requireValidRevision(fingerprint, payload) + return db.inTransaction { + val latest = db.rawQuery( + "SELECT id, fingerprint FROM weekly_report_history " + + "WHERE period_start = ? ORDER BY recorded_at DESC, id DESC LIMIT 1", + arrayOf(periodStartEpochDay.toString()), + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getLong(0) to cursor.getString(1) else null + } + if (latest?.second == fingerprint) return@inTransaction latest.first + val id = db.insertOrThrow( + "weekly_report_history", + null, + historyRow(periodStartEpochDay, recordedAt, fingerprint, payload), + ) + if (clearActiveOnChange) { + db.delete( + "weekly_report_history_state", + "period_start = ?", + arrayOf(periodStartEpochDay.toString()), + ) + } + db.delete( + "weekly_report_history", + "period_start = ? AND id NOT IN (" + + "SELECT id FROM weekly_report_history WHERE period_start = ? " + + "ORDER BY recorded_at DESC, id DESC LIMIT ?)", + arrayOf( + periodStartEpochDay.toString(), + periodStartEpochDay.toString(), + MAX_WEEKLY_REPORT_HISTORY.toString(), + ), + ) + id + } + } + + fun list(db: SQLiteDatabase, periodStartEpochDay: Long): List = + db.rawQuery( + "SELECT history.id, history.recorded_at, " + + "CASE WHEN state.active_history_id = history.id THEN 1 ELSE 0 END " + + "FROM weekly_report_history history " + + "LEFT JOIN weekly_report_history_state state " + + "ON state.period_start = history.period_start " + + "WHERE history.period_start = ? " + + "ORDER BY history.recorded_at DESC, history.id DESC LIMIT ?", + arrayOf(periodStartEpochDay.toString(), MAX_WEEKLY_REPORT_HISTORY.toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add( + WeeklyReportHistoryEntry( + id = cursor.getLong(0), + periodStart = LocalDate.ofEpochDay(periodStartEpochDay), + recordedAt = Instant.ofEpochMilli(cursor.getLong(1)), + active = cursor.getInt(2) != 0, + ), + ) + } + } + } + + fun payload(db: SQLiteDatabase, id: Long, periodStartEpochDay: Long): ByteArray? = + db.rawQuery( + "SELECT report_blob FROM weekly_report_history WHERE id = ? AND period_start = ?", + arrayOf(id.toString(), periodStartEpochDay.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getBlob(0) else null } + + fun activePayload(db: SQLiteDatabase, periodStartEpochDay: Long): ByteArray? = + db.rawQuery( + "SELECT history.report_blob 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 } + + fun activate(db: SQLiteDatabase, id: Long, periodStartEpochDay: Long): Boolean { + val exists = db.exists( + "SELECT 1 FROM weekly_report_history WHERE id = ? AND period_start = ?", + id, + periodStartEpochDay, + ) + if (!exists) return false + db.insertWithOnConflict( + "weekly_report_history_state", + null, + ContentValues().apply { + put("period_start", periodStartEpochDay) + put("active_history_id", id) + }, + SQLiteDatabase.CONFLICT_REPLACE, + ) + return true + } + + fun clearActive(db: SQLiteDatabase, periodStartEpochDay: Long): Boolean = + db.delete( + "weekly_report_history_state", + "period_start = ?", + arrayOf(periodStartEpochDay.toString()), + ) > 0 + + fun clear(db: SQLiteDatabase) { + db.inTransaction { deleteAll(db) } + } + + /** + * Replaces every derived weekly revision in one SQLite transaction, validating the complete + * replacement before deleting existing history. + */ + fun replace(db: SQLiteDatabase, replacements: List) { + require(replacements.map { it.periodStartEpochDay }.distinct().size == replacements.size) + replacements.forEach { requireValidRevision(it.fingerprint, it.payload) } + db.inTransaction { + deleteAll(db) + replacements.forEach { replacement -> + db.insertOrThrow( + "weekly_report_history", + null, + historyRow( + replacement.periodStartEpochDay, + replacement.recordedAt, + replacement.fingerprint, + replacement.payload, + ), + ) + } + } + } + + private fun deleteAll(db: SQLiteDatabase) { + db.delete("weekly_report_history_state", null, null) + db.delete("weekly_report_history", null, null) + } + + private fun historyRow( + periodStartEpochDay: Long, + recordedAt: Instant, + fingerprint: String, + payload: ByteArray, + ) = ContentValues().apply { + put("period_start", periodStartEpochDay) + put("recorded_at", recordedAt.toEpochMilli()) + put("fingerprint", fingerprint) + put("report_blob", payload) + } + + private fun requireValidRevision(fingerprint: String, payload: ByteArray) { + require(fingerprint.matches(FINGERPRINT)) + require(payload.size in 1..WeeklyReportHistoryCodec.MAX_PAYLOAD_BYTES) + } + + private val FINGERPRINT = Regex("[0-9a-f]{64}") +} From c9380cf45ffc93281c068e889aa8fe3aee6bbaa5 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:19:32 +0900 Subject: [PATCH 018/125] feat: rebuild the weekly table around a frozen, scrollable grid - Sticky day header and frozen member column with synced horizontal scrolling in one virtualized list; 12sp+ cells instead of 8-11sp. - Tapping a cell explains every metric of that day in one sheet; edit mode edits one day at a time with tri-state Login/Patrol choices, highlighted changes, cancel with discard confirmation, and drafts that survive recreation. - Notes are deleted only through an explicit delete action; week navigator, evidence summary, and a live-table action for restored history; exports regenerate content instead of holding it in memory. - History preview renders the native table instead of a bitmap, restore cannot be submitted twice, and cutline settings validate per field. - Shared weekly PNGs use localized evidence text and the app palette. --- .../PlatoonBackupManagerIntegrationTest.kt | 45 +- .../java/dev/gf2log/app/PlatoonActivity.kt | 3 +- .../main/java/dev/gf2log/app/WeeklyDialogs.kt | 285 ++ .../java/dev/gf2log/app/WeeklyPngExport.kt | 93 + .../dev/gf2log/app/WeeklyReportActivity.kt | 2327 +++++------------ .../dev/gf2log/app/WeeklyReportPngRenderer.kt | 39 +- .../dev/gf2log/app/WeeklySettingsActivity.kt | 280 +- .../main/java/dev/gf2log/app/WeeklyTable.kt | 404 +++ .../gf2log/app/WeeklyTableHistoryActivity.kt | 200 +- .../app/WeeklyTableHistoryPreviewActivity.kt | 255 +- app/src/main/java/dev/gf2log/app/ui/Labels.kt | 14 +- app/src/main/res/values-ko/strings.xml | 65 +- app/src/main/res/values/ids.xml | 8 + app/src/main/res/values/strings.xml | 66 +- .../management/WeeklyShareProjectionTest.kt | 22 +- 15 files changed, 1986 insertions(+), 2120 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt create mode 100644 app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt create mode 100644 app/src/main/java/dev/gf2log/app/WeeklyTable.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 7d604b0..34c89d2 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -9,7 +9,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import dev.gf2log.app.TargetPackagePreferences import dev.gf2log.app.SupportedGamePackages -import dev.gf2log.app.WeeklyPngPendingState +import dev.gf2log.app.WeeklyPngExport import dev.gf2log.app.WeeklyReportActivity import dev.gf2log.app.settings.AppBackupSettings import dev.gf2log.app.settings.AppBackupSettingsCodec @@ -979,37 +979,20 @@ class WeeklyReportActivityStateTest { closingBoundaries = 0, ), ) - val writeMethod = WeeklyReportActivity::class.java.getDeclaredMethod( - "writeWeeklyPng", - WeeklyShareProjection.Document::class.java, - LocalDate::class.java, - ).apply { isAccessible = true } try { - ActivityScenario.launch(WeeklyReportActivity::class.java).use { scenario -> - scenario.onActivity { activity -> - val first = writeMethod.invoke(activity, document, periodStart) as java.io.File - val firstUri = FileProvider.getUriForFile( - activity, - activity.packageName + ".fileprovider", - first, - ) - val second = writeMethod.invoke(activity, document, periodStart) as java.io.File - val secondUri = FileProvider.getUriForFile( - activity, - activity.packageName + ".fileprovider", - second, - ) - - assertFalse(first.exists()) - assertNotEquals(first.canonicalPath, second.canonicalPath) - assertNotEquals(firstUri, secondUri) - activity.contentResolver.openInputStream(secondUri).use { input -> - assertTrue(input != null && input.read() >= 0) - } - } + val first = WeeklyPngExport.render(context, document, periodStart) + val firstUri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", first) + val second = WeeklyPngExport.render(context, document, periodStart) + val secondUri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", second) + + assertFalse(first.exists()) + assertNotEquals(first.canonicalPath, second.canonicalPath) + assertNotEquals(firstUri, secondUri) + context.contentResolver.openInputStream(secondUri).use { input -> + assertTrue(input != null && input.read() >= 0) } } finally { - WeeklyPngPendingState.directory(context.cacheDir) + WeeklyPngExport.directory(context.cacheDir) .listFiles() .orEmpty() .forEach(java.io.File::delete) @@ -1018,8 +1001,8 @@ class WeeklyReportActivityStateTest { @Test fun pendingWeeklyPngSurvivesActivityRecreation() { - WeeklyPngPendingState.directory(context.cacheDir).mkdirs() - val target = WeeklyPngPendingState + WeeklyPngExport.directory(context.cacheDir).mkdirs() + val target = WeeklyPngExport .newRenderTarget(context.cacheDir, LocalDate.of(2026, 8, 9)) .apply { writeBytes(byteArrayOf(1, 2, 3)) } val field = WeeklyReportActivity::class.java.getDeclaredField("pendingPng").apply { diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index 467ca40..bf22426 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -494,8 +494,7 @@ class PlatoonActivity : BaseActivity() { check.isChecked = member.uid in selected name.text = member.name status.text = getString(if (member.isActive) R.string.active_member else R.string.departed_member) - status.background.setTint(getColor(if (member.isActive) R.color.success_surface else R.color.surface_variant)) - status.setTextColor(getColor(if (member.isActive) R.color.success_text else R.color.text_secondary)) + status.visibility = if (member.isActive) View.GONE else View.VISIBLE detail.text = buildList { add("#${member.uid}") add(getString(R.string.level_short, latest?.level ?: member.level)) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt new file mode 100644 index 0000000..5e30059 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -0,0 +1,285 @@ +package dev.gf2log.app + +import android.app.Activity +import android.app.AlertDialog +import android.text.InputType +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import dev.gf2log.app.management.MetricCertainty +import dev.gf2log.app.management.WeeklyEvidenceAnalyzer +import dev.gf2log.app.management.WeeklyEvidenceAnalyzer.Metric +import dev.gf2log.app.management.WeeklyOverrideEditing +import dev.gf2log.app.management.WeeklyOverrideEditing.Choice +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.management.WeeklyShareProjection +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.switchRow +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField + +/** Weekly-table dialogs: evidence explanations, manual cell edits, the guide, and sharing. */ +internal object WeeklyDialogs { + /** Explains every metric of one member-day (or weekly total) in a single sheet. */ + fun showEvidence( + activity: Activity, + title: String, + isGunsmokeWeek: Boolean, + display: CellDisplay, + explanations: Map, + ) { + val metrics = buildList { + add(Metric.MERIT to display.merit) + if (isGunsmokeWeek) { + add(Metric.SCORE to (display.score ?: "-")) + add(Metric.ATTEMPTS to (display.attempts ?: "-")) + } + add(Metric.LOGIN to display.login.symbol) + add(Metric.DAILY_PATROL to display.patrol.symbol) + } + val content = Dialogs.content(activity) { + metrics.forEachIndexed { index, (metric, value) -> + 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)) + }, activity.lp()) + addView(certaintyBadge(activity, explanation.certainty), activity.lp(WRAP, WRAP, top = 4)) + explanation.facts.ifEmpty { listOf(WeeklyEvidenceAnalyzer.Fact.NO_OBSERVATION) }.forEach { fact -> + addView(activity.text("• " + activity.getString(factString(fact)), TextRole.SECONDARY), activity.lp(top = 4)) + } + } + } + Dialogs.show( + Dialogs.builder(activity) + .setTitle(title) + .setView(content) + .setPositiveButton(R.string.done, null), + ) + } + + /** + * Edits one member-day. Fields start from the displayed values; only fields the user changes + * become edits, so untouched derived values keep their certainty. + */ + fun showEdit( + activity: Activity, + title: String, + isGunsmokeWeek: Boolean, + uid: Long, + cell: WeeklyReportBuilder.DayCell, + current: WeeklyOverrideEditing.CellEdit?, + 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 + } + val (merit, meritInitial) = numberField(R.string.merit_short, current?.merit ?: cell.meritDelta?.toString().orEmpty(), R.string.blank_clears) + val score = if (isGunsmokeWeek) { + numberField(R.string.point_short, current?.score ?: cell.scoreDelta?.toString().orEmpty(), 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) + } else { + null + } + val loginInitial = current?.attended ?: Choice.of(cell.attended) + val patrolInitial = current?.dailyPatrol ?: Choice.of(cell.dailyPatrol) + var login = loginInitial + var patrol = patrolInitial + val error = activity.text(null, TextRole.CAPTION) { + setTextColor(activity.getColor(R.color.destructive_action)) + visibility = View.GONE + } + val content = Dialogs.content(activity) { + addView(activity.text(activity.getString(R.string.manual_edit_hint), TextRole.SECONDARY), activity.lp()) + addView(merit, activity.lp(top = 12)) + if (score != null && attempts != null) { + addView(activity.row(Gravity.TOP) { + addView(score.first, activity.lp(width = 0, weight = 1f)) + addView(attempts.first, activity.lp(width = 0, weight = 1f, start = 12)) + }, activity.lp(top = 12)) + } + addView(choiceRow(activity, R.string.login_short, loginInitial) { login = it }, activity.lp(top = 12)) + addView(choiceRow(activity, R.string.patrol_short, patrolInitial) { patrol = it }, activity.lp(top = 12)) + addView(error, activity.lp(top = 8)) + } + val builder = Dialogs.builder(activity) + .setTitle(title) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.apply, null) + if (cell.manualOverride != null) builder.setNeutralButton(R.string.clear_manual_values, null) + Dialogs.show(builder) { dialog -> + fun changed(field: Pair?): String? = + field?.let { (view, initial) -> view.input.text.toString().takeIf { it != initial } } + fun finish(edit: WeeklyOverrideEditing.CellEdit) { + val probe = WeeklyOverrideEditing.merge( + cell.manualOverride?.periodStart ?: cell.gameDay, + isGunsmokeWeek, + emptyList(), + listOf(edit), + ) + if (probe is WeeklyOverrideEditing.Result.Invalid) { + error.text = activity.getString( + if (probe.field == WeeklyOverrideEditing.Field.ATTEMPTS) R.string.invalid_attempts else R.string.invalid_counter, + ) + error.visibility = View.VISIBLE + error.announceForAccessibility(error.text) + return + } + dialog.dismiss() + onDone(edit) + } + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + finish( + WeeklyOverrideEditing.CellEdit( + uid = uid, + gameDay = cell.gameDay, + merit = changed(merit to meritInitial), + score = changed(score), + attempts = changed(attempts), + attended = login.takeIf { it != loginInitial } ?: current?.attended, + dailyPatrol = patrol.takeIf { it != patrolInitial } ?: current?.dailyPatrol, + ), + ) + } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { + finish( + WeeklyOverrideEditing.CellEdit( + uid = uid, + gameDay = cell.gameDay, + merit = "", + score = if (isGunsmokeWeek) "" else null, + attempts = if (isGunsmokeWeek) "" else null, + attended = Choice.UNSET, + dailyPatrol = Choice.UNSET, + ), + ) + } + } + } + + private fun choiceRow(activity: Activity, label: Int, initial: Choice, onChanged: (Choice) -> Unit): View = + activity.column { + addView(activity.text(activity.getString(label), TextRole.LABEL) { + setPadding(activity.dp(4), 0, 0, activity.dp(4)) + }, activity.lp()) + addView(ModernUi.segmentedControl( + context = activity, + options = listOf( + Choice.UNSET.name to activity.getString(R.string.choice_unset), + Choice.YES.name to activity.getString(R.string.yes), + Choice.NO.name to activity.getString(R.string.no), + ), + selectedValue = initial.name, + ) { onChanged(Choice.valueOf(it)) }, activity.lp()) + } + + fun showGuide(activity: Activity, isGunsmokeWeek: Boolean) { + val rows = listOf( + "✓" to activity.getString(R.string.evidence_guide_exact), + "≥" to activity.getString(R.string.evidence_guide_minimum), + "?" to activity.getString(R.string.evidence_guide_unknown), + activity.getString(R.string.login_initial) + " / " + activity.getString(R.string.patrol_initial) to + activity.getString(R.string.evidence_guide_marks), + (if (isGunsmokeWeek) activity.getString(R.string.total) else "0 / 50 / 90") to activity.getString( + if (isGunsmokeWeek) R.string.evidence_guide_gunsmoke_total else R.string.evidence_guide_standard_allocation, + ), + ) + val content = Dialogs.content(activity) { + rows.forEachIndexed { index, (symbol, description) -> + addView(activity.row(Gravity.TOP) { + addView(activity.badge(symbol, R.color.accent_surface, R.color.accent_text).apply { + textSize = 15f + minWidth = activity.dp(56) + minHeight = activity.dp(32) + }, activity.lp(WRAP, WRAP, end = 12)) + addView(activity.text(description, TextRole.BODY), activity.lp(width = 0, weight = 1f)) + }, activity.lp(top = if (index == 0) 0 else 12)) + } + addView(activity.text(activity.getString(R.string.evidence_guide_gap_warning), TextRole.SECONDARY), activity.lp(top = 16)) + } + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.weekly_evidence_guide) + .setView(content) + .setPositiveButton(R.string.done, null), + ) + } + + fun showShare(activity: Activity, onChosen: (WeeklyShareProjection.Privacy, Boolean) -> Unit) { + var names = true + var uids = false + var notes = false + val content = Dialogs.content(activity) { + setPadding(activity.dp(16), activity.dp(4), activity.dp(16), 0) + addView(activity.switchRow(activity.getString(R.string.share_include_member_names), null, names) { names = it }, activity.lp()) + addView(activity.switchRow(activity.getString(R.string.share_include_uids), null, uids) { uids = it }, activity.lp()) + addView(activity.switchRow( + activity.getString(R.string.share_include_private_notes), + activity.getString(R.string.share_private_notes_detail), + notes, + ) { notes = it }, activity.lp()) + addView(activity.text(activity.getString(R.string.share_privacy_notice), TextRole.CAPTION) { + setPadding(activity.dp(4), activity.dp(8), activity.dp(4), 0) + }, activity.lp()) + } + fun privacy() = WeeklyShareProjection.Privacy(names, uids, notes) + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.share_weekly_table) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setNeutralButton(R.string.save_png) { _, _ -> onChosen(privacy(), false) } + .setPositiveButton(R.string.share_png) { _, _ -> onChosen(privacy(), true) }, + ) + } + + private fun certaintyBadge(activity: Activity, certainty: MetricCertainty) = when (certainty) { + MetricCertainty.EXACT -> activity.badge(activity.getString(R.string.evidence_certainty_exact), R.color.success_surface, R.color.success_text) + MetricCertainty.LOWER_BOUND -> activity.badge(activity.getString(R.string.evidence_certainty_lower_bound), R.color.warning_surface, R.color.warning_text) + MetricCertainty.UNKNOWN -> activity.badge(activity.getString(R.string.evidence_certainty_unknown), R.color.surface_variant, R.color.text_secondary) + }.apply { maxLines = 2 } + + fun metricLabel(activity: Activity, metric: Metric): String = activity.getString( + when (metric) { + Metric.MERIT -> R.string.merit_short + Metric.SCORE -> R.string.point_short + Metric.ATTEMPTS -> R.string.attempt_short + Metric.LOGIN -> R.string.login_short + Metric.DAILY_PATROL -> R.string.patrol_short + }, + ) + + private fun factString(fact: WeeklyEvidenceAnalyzer.Fact): Int = when (fact) { + WeeklyEvidenceAnalyzer.Fact.MANUAL_OVERRIDE -> R.string.evidence_fact_manual + WeeklyEvidenceAnalyzer.Fact.EXACT_CLOSING_BOUNDARY -> R.string.evidence_fact_closing + WeeklyEvidenceAnalyzer.Fact.FINAL_GUNSMOKE_SCORE -> R.string.evidence_fact_final_score + WeeklyEvidenceAnalyzer.Fact.EXACT_DAILY_PATROL_EVENT -> R.string.evidence_fact_patrol + WeeklyEvidenceAnalyzer.Fact.LOGIN_TIMESTAMP -> R.string.evidence_fact_login + WeeklyEvidenceAnalyzer.Fact.SOLVER_CONSENSUS -> R.string.evidence_fact_solver + WeeklyEvidenceAnalyzer.Fact.DAILY_CAP_REACHED -> R.string.evidence_fact_daily_cap + WeeklyEvidenceAnalyzer.Fact.WEEKLY_CAP_REACHED -> R.string.evidence_fact_weekly_cap + WeeklyEvidenceAnalyzer.Fact.ALL_DAYS_EXACT -> R.string.evidence_fact_all_exact + WeeklyEvidenceAnalyzer.Fact.CONFIRMED_LOWER_BOUND -> R.string.evidence_fact_lower_bound + WeeklyEvidenceAnalyzer.Fact.NO_OBSERVATION -> R.string.evidence_fact_no_observation + WeeklyEvidenceAnalyzer.Fact.INCOMPLETE_BOUNDARY -> R.string.evidence_fact_incomplete_boundary + WeeklyEvidenceAnalyzer.Fact.PARTIAL_DAY -> R.string.evidence_fact_partial + WeeklyEvidenceAnalyzer.Fact.SPARSE_INFERENCE -> R.string.evidence_fact_sparse + WeeklyEvidenceAnalyzer.Fact.AMBIGUOUS_ALLOCATION -> R.string.evidence_fact_ambiguous + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt b/app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt new file mode 100644 index 0000000..8d43536 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt @@ -0,0 +1,93 @@ +package dev.gf2log.app + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import androidx.core.content.FileProvider +import dev.gf2log.app.management.WeeklyShareProjection +import java.io.File +import java.io.FileOutputStream +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.UUID + +/** + * Renders privacy-filtered weekly PNGs into a private share cache. Each render receives a fresh + * UUID-backed name; stale FileProvider grants are revoked before their files are removed, and only + * the validated cache name survives Activity recreation. + */ +internal object WeeklyPngExport { + fun directory(cacheDirectory: File): File = File(cacheDirectory, DIRECTORY_NAME) + + fun exportName(periodStart: LocalDate): String = "GF2logger-week-${periodStart.format(DATE)}.png" + + fun render(context: Context, document: WeeklyShareProjection.Document, periodStart: LocalDate): File { + val directory = directory(context.cacheDir).apply { mkdirs() } + require(directory.isDirectory) { "Unable to create weekly share cache" } + revokeAndDeleteOld(context, directory) + val target = newRenderTarget(context.cacheDir, periodStart) + val temporary = File.createTempFile(".weekly-", ".png", directory) + val bitmap = WeeklyReportPngRenderer.render(context, document) + try { + FileOutputStream(temporary).use { output -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) { "Unable to encode weekly PNG" } + output.fd.sync() + } + check(temporary.renameTo(target)) { "Unable to publish weekly PNG" } + } finally { + bitmap.recycle() + temporary.delete() + } + return target + } + + fun share(context: Context, file: File) { + val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file) + val title = context.getString(R.string.share_weekly_table) + val share = Intent(Intent.ACTION_SEND) + .setType("image/png") + .putExtra(Intent.EXTRA_STREAM, uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + share.clipData = ClipData.newUri(context.contentResolver, title, uri) + context.startActivity(Intent.createChooser(share, title)) + } + + internal fun newRenderTarget(cacheDirectory: File, periodStart: LocalDate): File { + val root = directory(cacheDirectory).canonicalFile + require(root.isDirectory) { "Weekly PNG cache is unavailable" } + val candidate = File(root, "GF2logger-week-${periodStart.format(DATE)}-${UUID.randomUUID()}.png").canonicalFile + check(candidate.parentFile == root && !candidate.exists()) { + "Weekly PNG target must be a new private cache file" + } + return candidate + } + + fun nameForState(cacheDirectory: File, pendingFile: File?): String? = runCatching { + val candidate = pendingFile?.canonicalFile ?: return@runCatching null + val root = directory(cacheDirectory).canonicalFile + candidate.name.takeIf { candidate.isFile && candidate.parentFile == root && it.matches(FILE_NAME) } + }.getOrNull() + + fun restore(cacheDirectory: File, savedName: String?): File? = runCatching { + val name = savedName?.takeIf { it.matches(FILE_NAME) } ?: return@runCatching null + val root = directory(cacheDirectory).canonicalFile + File(root, name).canonicalFile.takeIf { it.isFile && it.parentFile == root } + }.getOrNull() + + private fun revokeAndDeleteOld(context: Context, directory: File) { + directory.listFiles().orEmpty().filter(File::isFile).forEach { stale -> + runCatching { + val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", stale) + context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + stale.delete() + } + } + + private const val DIRECTORY_NAME = "shared-weekly" + private val DATE: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE + private val FILE_NAME = Regex( + "GF2logger-week-\\d{8}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.png", + ) +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 3c93047..34267e9 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -1,158 +1,183 @@ package dev.gf2log.app -import android.app.AlertDialog import android.app.DatePickerDialog import android.content.ClipData import android.content.ClipboardManager import android.content.Intent -import android.graphics.Color -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable import android.os.Bundle -import android.text.Editable -import android.text.InputType -import android.text.SpannableString -import android.text.Spanned -import android.text.TextWatcher -import android.text.style.ForegroundColorSpan +import android.text.TextUtils import android.view.Gravity import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.Button -import android.widget.CheckBox import android.widget.EditText import android.widget.FrameLayout -import android.widget.HorizontalScrollView -import android.widget.ImageButton import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.Spinner import android.widget.TextView -import android.widget.Toast -import androidx.core.content.FileProvider +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import dev.gf2log.app.management.PlatoonPeriods -import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.MemberEvent import dev.gf2log.app.management.MemberEventType -import dev.gf2log.app.management.EvidenceSource import dev.gf2log.app.management.MembershipEventPresentation -import dev.gf2log.app.management.DailyEvidence -import dev.gf2log.app.management.MetricCertainty -import dev.gf2log.app.management.WeeklyCellOverride +import dev.gf2log.app.management.PlatoonPeriods +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.management.WeeklyEvidenceAnalyzer +import dev.gf2log.app.management.WeeklyMemberNameProjection import dev.gf2log.app.management.WeeklyNote import dev.gf2log.app.management.WeeklyNoteLimitException +import dev.gf2log.app.management.WeeklyOverrideEditing import dev.gf2log.app.management.WeeklyReportBuilder import dev.gf2log.app.management.WeeklyReportCsv import dev.gf2log.app.management.WeeklyReportStateHolder import dev.gf2log.app.management.WeeklyShareProjection -import dev.gf2log.app.management.WeeklyMetricPresentation -import dev.gf2log.app.management.WeeklyMemberNameProjection -import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines -import java.time.Instant +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.MATCH +import dev.gf2log.app.ui.MenuEntry +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.SingleViewAdapter +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.iconButton +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField import java.io.File -import java.io.FileOutputStream +import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.UUID -import java.util.concurrent.Executors -/** Keeps a pending weekly PNG bound to the app-private share cache across recreation. */ -internal object WeeklyPngPendingState { - fun directory(cacheDirectory: File): File = File(cacheDirectory, DIRECTORY_NAME) +/** + * The weekly table: a frozen member column, synced horizontal day columns, and a sticky header in + * one vertical list. Tapping a cell explains its evidence; edit mode turns taps into manual edits. + */ +class WeeklyReportActivity : BaseActivity() { + override val legacyStyling = false + + private lateinit var profileBinding: ActivePlatoonScopeBinding + private lateinit var scope: PlatoonStorageScope + private lateinit var repository: PlatoonRepository + private lateinit var reportState: WeeklyReportStateHolder + private lateinit var screen: Screen + private lateinit var list: RecyclerView + private lateinit var stickyHeader: FrameLayout + private lateinit var bottomSections: LinearLayout + private lateinit var noteInput: EditText + private lateinit var noteDayButton: TextView + private val work = BackgroundWork(this, "GF2Weekly") + + private val topAdapter = SingleViewAdapter() + private val headerAdapter = SingleViewAdapter() + private val rowsAdapter = RowsAdapter() + private val stateAdapter = SingleViewAdapter() + private val bottomAdapter = SingleViewAdapter() + + private var model: RenderModel? = null + private var table: WeeklyTable? = null + private var tableGeneration = 0 + private var editingPeriod: LocalDate? = null + private val edits = linkedMapOf, WeeklyOverrideEditing.CellEdit>() + private var noteDay: LocalDate? = null + private var pendingExport: String? = null + private var pendingPng: File? = null - fun newRenderTarget(cacheDirectory: File, periodStart: LocalDate): File { - val root = directory(cacheDirectory).canonicalFile - require(root.isDirectory) { "Weekly PNG cache is unavailable" } - val candidate = File( - root, - "GF2logger-week-${periodStart.format(DATE)}-${UUID.randomUUID()}.png", - ).canonicalFile - check(candidate.parentFile == root && !candidate.exists()) { - "Weekly PNG target must be a new private cache file" + private val exportCsv = registerForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { uri -> + val request = pendingExport + pendingExport = null + if (uri == null || request == null) return@registerForActivityResult + work.run( + task = { + val zone = GameTimeZonePreferences.get(this, scope.storageId) + val csv = if (request == EXPORT_ALL) { + WeeklyReportCsv.formatAll(repository.listAllWeeklyReports(zone)) + } else { + WeeklyReportCsv.format( + repository.buildWeeklyTableRevision(LocalDate.ofEpochDay(request.toLong()), zone).report, + ) + } + DocumentAccess.openOutput(contentResolver, uri).writer(Charsets.UTF_8).use { it.write(csv) } + }, + ) { result -> + Feedback.show(this, if (result.isSuccess) R.string.weekly_csv_exported else R.string.status_export_failed) } - return candidate } - fun exportName(periodStart: LocalDate): String = - "GF2logger-week-${periodStart.format(DATE)}.png" - - fun nameForState(cacheDirectory: File, pendingFile: File?): String? = runCatching { - val candidate = pendingFile?.canonicalFile ?: return@runCatching null - val root = directory(cacheDirectory).canonicalFile - candidate.name.takeIf { - candidate.isFile && candidate.parentFile == root && it.matches(FILE_NAME) + private val exportPng = registerForActivityResult(ActivityResultContracts.CreateDocument("image/png")) { uri -> + val source = pendingPng + pendingPng = null + if (uri == null || source == null) { + source?.delete() + return@registerForActivityResult } - }.getOrNull() - - fun restore(cacheDirectory: File, savedName: String?): File? = runCatching { - val name = savedName?.takeIf { it.matches(FILE_NAME) } ?: return@runCatching null - val root = directory(cacheDirectory).canonicalFile - File(root, name).canonicalFile.takeIf { candidate -> - candidate.isFile && candidate.parentFile == root + work.run( + task = { + DocumentAccess.openOutput(contentResolver, uri).use { output -> + source.inputStream().use { it.copyTo(output) } + } + }, + ) { result -> + source.delete() + Feedback.show(this, if (result.isSuccess) R.string.weekly_png_saved else R.string.weekly_png_failed) } - }.getOrNull() - - private const val DIRECTORY_NAME = "shared-weekly" - private val DATE = DateTimeFormatter.BASIC_ISO_DATE - private val FILE_NAME = Regex( - "GF2logger-week-\\d{8}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.png", - ) -} + } -class WeeklyReportActivity : BaseActivity() { - private lateinit var repository: PlatoonRepository - private lateinit var profileBinding: ActivePlatoonScopeBinding - private lateinit var body: LinearLayout - private lateinit var reportState: WeeklyReportStateHolder - private var pendingCsv: String? = null - private var pendingPng: File? = null - private val workerExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2WeeklyWorker") + private val editingBack = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() = cancelEditing() } - private var editingPeriodStart: LocalDate? = null - private val editDraft = mutableMapOf() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) profileBinding = ActivePlatoonScopeBinding(this) - val scope = profileBinding.scope ?: run { - TransientMessage.show(this, R.string.no_platoon_detected_detail, Toast.LENGTH_LONG) + scope = profileBinding.scope ?: run { + Feedback.show(this, R.string.no_platoon_detected_detail, long = true) finish() return } repository = PlatoonRepository(this, scope) - pendingPng = WeeklyPngPendingState.restore( - cacheDir, - savedInstanceState?.getString(STATE_PENDING_PNG_NAME), - ) - reportState = WeeklyReportStateHolder( - savedInstanceState?.takeIf { it.containsKey(STATE_REFERENCE_DAY) } - ?.getLong(STATE_REFERENCE_DAY) - ?.let(LocalDate::ofEpochDay) - ?: PlatoonPeriods.gameDay( - Instant.now(), - GameTimeZonePreferences.get(this, scope.storageId), - ), - ) - body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(16), dp(16), dp(16)) + restoreState(savedInstanceState) + onBackPressedDispatcher.addCallback(this, editingBack) + screen = Screen(this, PrimaryNavigation.Destination.WEEKLY) + buildNoteComposer() + bottomSections = column() + bottomAdapter.view = column { + setPadding(dp(16), 0, dp(16), dp(24)) + addView(bottomSections, lp()) + } + list = RecyclerView(this).apply { + id = R.id.screen_list + layoutManager = LinearLayoutManager(context) + adapter = ConcatAdapter(topAdapter, headerAdapter, rowsAdapter, stateAdapter, bottomAdapter) + itemAnimator = null + addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) = updateStickyHeader() + }) } - setContentView( - PrimaryNavigation.wrap( - this, - ScrollView(this).apply { addView(body, matchWidth()) }, - PrimaryNavigation.Destination.WEEKLY, - ), - ) + stickyHeader = FrameLayout(this).apply { visibility = View.GONE } + screen.setBody(list) + (list.parent as ViewGroup).addView(stickyHeader, FrameLayout.LayoutParams(MATCH, WRAP, Gravity.TOP)) + updateTopBar() } override fun onScreenResumed() { @@ -165,60 +190,77 @@ class WeeklyReportActivity : BaseActivity() { } override fun onPause() { - reportState.onPause() + if (::reportState.isInitialized) reportState.onPause() super.onPause() } + override fun onSaveInstanceState(outState: Bundle) { - outState.putLong(STATE_REFERENCE_DAY, reportState.referenceDay.toEpochDay()) - WeeklyPngPendingState.nameForState(cacheDir, pendingPng)?.let { - outState.putString(STATE_PENDING_PNG_NAME, it) + if (::reportState.isInitialized) { + outState.putLong(STATE_REFERENCE_DAY, reportState.referenceDay.toEpochDay()) } + WeeklyPngExport.nameForState(cacheDir, pendingPng)?.let { outState.putString(STATE_PENDING_PNG_NAME, it) } + pendingExport?.let { outState.putString(STATE_PENDING_EXPORT, it) } + editingPeriod?.let { period -> + outState.putLong(STATE_EDITING_PERIOD, period.toEpochDay()) + outState.putParcelableArrayList(STATE_EDITS, ArrayList(edits.values.map(::encodeEdit))) + } + noteDay?.let { outState.putLong(STATE_NOTE_DAY, it.toEpochDay()) } super.onSaveInstanceState(outState) } - - override fun onDestroy() { - workerExecutor.shutdownNow() - super.onDestroy() + private fun restoreState(state: Bundle?) { + pendingPng = WeeklyPngExport.restore(cacheDir, state?.getString(STATE_PENDING_PNG_NAME)) + pendingExport = state?.getString(STATE_PENDING_EXPORT) + reportState = WeeklyReportStateHolder( + state?.takeIf { it.containsKey(STATE_REFERENCE_DAY) } + ?.getLong(STATE_REFERENCE_DAY) + ?.let(LocalDate::ofEpochDay) + ?: PlatoonPeriods.gameDay(Instant.now(), GameTimeZonePreferences.get(this, scope.storageId)), + ) + if (state?.containsKey(STATE_EDITING_PERIOD) == true) { + editingPeriod = LocalDate.ofEpochDay(state.getLong(STATE_EDITING_PERIOD)) + @Suppress("DEPRECATION") + state.getParcelableArrayList(STATE_EDITS) + ?.map(::decodeEdit) + ?.forEach { edits[it.uid to it.gameDay] = it } + } + noteDay = state?.takeIf { it.containsKey(STATE_NOTE_DAY) } + ?.getLong(STATE_NOTE_DAY) + ?.let(LocalDate::ofEpochDay) } - // Function Name: requestRender - // Description: - // - Loads the weekly projection and all supporting database state away from the main thread. - // - Uses a generation token so late results cannot replace a newer week or a paused screen. - // Parameters: - // - reconcileRetainedCsv: Whether retained CSV files must be reconciled before loading. - // Returns: - // - Returns immediately after scheduling the load. + // region Loading and rendering + private fun requestRender(reconcileRetainedCsv: Boolean = false) { val request = reportState.newRenderRequest() - showLoading() - workerExecutor.execute { - val result = runCatching { + if (model == null) stateAdapter.view = StateViews.loading(this, getString(R.string.weekly_report_loading)) + work.run( + key = RENDER, + task = { if (reconcileRetainedCsv) repository.reconcileRetainedCsvFiles() loadRenderModel(request.referenceDay) - } - runOnUiThread { - if (!canApplyRender(request.generation)) return@runOnUiThread - result.fold( - onSuccess = { render(it) }, - onFailure = { showLoadFailure() }, - ) - } + }, + ) { result -> + result.fold( + onSuccess = ::render, + onFailure = { + model = null + topAdapter.view = null + headerAdapter.view = null + stickyHeader.visibility = View.GONE + rowsAdapter.submit(emptyList()) + bottomSections.removeAllViews() + stateAdapter.view = StateViews.failure(this, getString(R.string.weekly_report_load_failed)) { + requestRender() + } + updateTopBar() + }, + ) } } - // Function Name: loadRenderModel - // Description: - // - Reads every repository and preference value needed by one weekly screen render. - // - Keeps later view construction free of database access and N+1 member membershipPeriod queries. - // Parameters: - // - targetDay: A date inside the requested reporting week. - // Returns: - // - An immutable render model for the requested week. private fun loadRenderModel(targetDay: LocalDate): RenderModel { - val zone = GameTimeZonePreferences.get(this) - val periodStart = PlatoonPeriods.weekStart(targetDay) + val zone = GameTimeZonePreferences.get(this, scope.storageId) val revision = repository.buildWeeklyTableRevision(targetDay, zone) val report = revision.report val history = repository.listWeeklyReportHistory(report.periodStart) @@ -231,1562 +273,605 @@ class WeeklyReportActivity : BaseActivity() { reportNamesByUid = report.members.associate { it.uid to it.name }, capturedNamesByUid = revision.memberNamesByUid, ), - cutlines = WeeklyCutlinePreferences(this).read(), + cutlines = WeeklyCutlinePreferences(this, scope.storageId).read(), memberNotesByUid = revision.memberPrivateNotesByUid, - displayedMembers = MemberOrderPreferences(this).apply(report.members) { it.uid }, + displayedMembers = MemberOrderPreferences(this, scope.storageId).apply(report.members) { it.uid }, scoreRanks = report.members.withIndex().associate { it.value.uid to it.index + 1 }, historyCount = history.size, restoredHistory = history.any { it.active }, + currentWeekStart = PlatoonPeriods.weekStart(PlatoonPeriods.gameDay(Instant.now(), zone)), ) } - private fun canApplyRender(generation: Int): Boolean = - reportState.canApply(generation) && !isFinishing && !isDestroyed - - private fun showLoading() { - body.removeAllViews() - body.addView(TextView(this).apply { - text = getString(R.string.weekly_report_loading) - gravity = Gravity.CENTER - setPadding(0, dp(32), 0, dp(32)) - }, matchWidth()) - } - - private fun showLoadFailure() { - body.removeAllViews() - body.addView(TextView(this).apply { - text = getString(R.string.weekly_report_load_failed) - gravity = Gravity.CENTER - setPadding(0, dp(32), 0, dp(12)) - }, matchWidth()) - body.addView(Button(this).apply { - text = getString(R.string.retry) - setOnClickListener { requestRender() } - }, matchWidth()) - } - - // Function Name: render - // Description: - // - Builds the weekly screen from an already-loaded immutable model. - // - Delegates member rows to a bounded RecyclerView viewport. - // Parameters: - // - model: Repository and preference state for one reporting week. - // Returns: - // - Unit after building the current screen projection. - private fun render(model: RenderModel) { - body.removeAllViews() - val zone = model.zone - val report = model.report - val isEditing = editingPeriodStart == report.periodStart - - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(TextView(context).apply { - text = getString( - if (report.isGunsmokeWeek) R.string.gunsmoke_week else R.string.off_week, - ) - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_history_restore) - useModernIconStyle() - contentDescription = getString(R.string.weekly_table_history) - setPadding(dp(10), dp(10), dp(10), dp(10)) - isEnabled = !isEditing && model.historyCount > 0 - alpha = if (isEnabled) 1f else 0.35f - setOnClickListener { - startActivity( - Intent(this@WeeklyReportActivity, WeeklyTableHistoryActivity::class.java) - .putExtra( - WeeklyTableHistoryActivity.EXTRA_PERIOD_START, - report.periodStart.toEpochDay(), - ), - ) - } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(8) }) - addView(ImageButton(context).apply { - setImageResource(if (isEditing) R.drawable.ic_save else R.drawable.ic_edit) - useModernIconStyle() - contentDescription = getString( - if (isEditing) R.string.save_weekly_edits else R.string.edit_weekly_table, - ) - setPadding(dp(10), dp(10), dp(10), dp(10)) - setOnClickListener { - if (isEditing) { - saveWeeklyEdits(report) - } else { - showManualEditWarning(report) - } - } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(8) }) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_share) - useModernIconStyle() - contentDescription = getString(R.string.share_weekly_table) - setPadding(dp(10), dp(10), dp(10), dp(10)) - isEnabled = !isEditing && report.members.isNotEmpty() - alpha = if (isEnabled) 1f else 0.35f - setOnClickListener { - showWeeklyShareOptions(model) - } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(8) }) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_settings) - useModernIconStyle() - contentDescription = getString(R.string.weekly_table_settings) - setPadding(dp(10), dp(10), dp(10), dp(10)) - isEnabled = !isEditing - alpha = if (isEditing) 0.35f else 1f - setOnClickListener { - startActivity( - Intent(this@WeeklyReportActivity, WeeklySettingsActivity::class.java), - ) - } - }, LinearLayout.LayoutParams(dp(48), dp(48))) - }, matchWidth()) - body.addView( - PlatoonProfileSelector.controls(this), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(6) }, - ) - body.addView(TextView(this).apply { - text = getString( - R.string.week_period, - report.periodStart.format(DATE), - report.periodEnd.format(DATE), - zone.id, - ) - textSize = 14f - setTextColor(getColor(R.color.text_secondary)) - }, matchWidth()) - if (model.restoredHistory) { - body.addView(TextView(this).apply { - text = getString(R.string.weekly_table_history_live_notice) - textSize = 13f - setTextColor(getColor(R.color.accent_text)) - background = ModernUi.panelBackground(context, emphasized = true) - setPadding(dp(12), dp(10), dp(12), dp(10)) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) - } - if (report.hasIncompleteDailyEvidence) { - body.addView(ModernUi.actionRow( - context = this, - title = getString(R.string.weekly_evidence_guide), - detail = getString(R.string.weekly_evidence_guide_summary), - onClick = { showWeeklyEvidenceGuide(report.isGunsmokeWeek) }, - ), LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) + private fun render(next: RenderModel) { + val report = next.report + if (editingPeriod != null && editingPeriod != report.periodStart) { + editingPeriod = null + edits.clear() } - addEvidenceHealthPanel(report) - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(Button(context).apply { - text = "\u2039" - useTertiaryActionStyle() - isEnabled = !isEditing - contentDescription = getString(R.string.previous_week) - setOnClickListener { - reportState.showPreviousWeek(report.periodStart) - requestRender() - } - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - addView(Button(context).apply { - text = getString(R.string.current_week) - useSecondaryActionStyle() - isEnabled = !isEditing - setOnClickListener { showDatePicker() } - }, LinearLayout.LayoutParams(0, wrap(), 2f)) - addView(Button(context).apply { - text = "\u203a" - useTertiaryActionStyle() - isEnabled = !isEditing - contentDescription = getString(R.string.next_week) - setOnClickListener { - reportState.showNextWeek(report.periodEnd) - requestRender() - } - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - }, matchWidth()) - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(Button(context).apply { - text = getString(R.string.export_weekly_csv) - useSecondaryActionStyle() - isEnabled = !isEditing - setOnClickListener { exportWeeklyCsv(report) } - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - addView(Button(context).apply { - text = getString(R.string.copy_weekly_csv) - useSecondaryActionStyle() - isEnabled = !isEditing - setOnClickListener { copyWeeklyCsv(report) } - }, LinearLayout.LayoutParams(0, wrap(), 1f)) - }, matchWidth()) - body.addView(Button(this).apply { - text = getString(R.string.export_all_weekly_tables) - useSecondaryActionStyle() - isEnabled = !isEditing - setOnClickListener { exportAllWeeklyTables() } - }, matchWidth()) - body.addView(Button(this).apply { - text = getString(R.string.edit_member_order) - useNavigationActionStyle() - isEnabled = !isEditing - setOnClickListener { - startActivity(Intent(this@WeeklyReportActivity, MemberOrderActivity::class.java)) - } - }, matchWidth()) - + model = next + val weeklyTable = WeeklyTable(this, report.isGunsmokeWeek, next.zone, next.cutlines) + table = weeklyTable + tableGeneration += 1 + topAdapter.view = buildTop(next) + stickyHeader.removeAllViews() if (report.members.isEmpty()) { - body.addView(TextView(this).apply { - text = getString(R.string.no_weekly_data) - setPadding(0, dp(16), 0, dp(16)) - }, matchWidth()) - } else { - body.addView(buildVirtualizedTable(model, isEditing), matchWidth()) - } - - if (isEditing) return - - addMembershipEvents(report, model.events, model.namesByUid, zone) - body.addView(TextView(this).apply { - text = getString(R.string.notes) - textSize = 21f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(16), 0, dp(4)) - }, matchWidth()) - report.days.forEach { day -> - val dayNotes = model.notes.filter { it.gameDay == day } - if (dayNotes.isNotEmpty()) { - body.addView(TextView(this).apply { - text = day.format(DATE) - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - dayNotes.forEach(::addNote) - } - } - addNoteEditor(report) - } - - - - // Function Name: buildVirtualizedTable - // Description: - // - Keeps the shared header and member rows in one horizontal coordinate space. - // - Bounds the vertical viewport so RecyclerView recycles expensive metric rows. - // Parameters: - // - model: Immutable data and display ordering for the selected week. - // - isEditing: Whether rows contain editable fields. - // Returns: - // - A horizontally scrollable table with a virtualized member list. - private fun buildVirtualizedTable( - model: RenderModel, - isEditing: Boolean, - ) = HorizontalScrollView(this).apply { - isFillViewport = false - val report = model.report - val rowHeight = dp(metricGroupHeight(report.isGunsmokeWeek)) - val tableWidth = dp( - (if (report.isGunsmokeWeek) RANK_WIDTH else 0) + - MEMBER_WIDTH + DAILY_WIDTH * (report.days.size + 1), - ) - val visibleRows = minOf( - MAX_VISIBLE_TABLE_ROWS, - model.displayedMembers.size, - ).coerceAtLeast(1) - val rows = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView( - headerRow(report), - LinearLayout.LayoutParams(tableWidth, dp(HEADER_HEIGHT)), - ) - addView( - RecyclerView(context).apply { - layoutManager = LinearLayoutManager(context) - adapter = WeeklyMemberAdapter( - model = model, - isEditing = isEditing, - tableWidth = tableWidth, - rowHeight = rowHeight, - ) - itemAnimator = null - setHasFixedSize(true) - }, - LinearLayout.LayoutParams(tableWidth, rowHeight * visibleRows), - ) - } - addView(rows) - } - - private inner class WeeklyMemberAdapter( - private val model: RenderModel, - private val isEditing: Boolean, - private val tableWidth: Int, - private val rowHeight: Int, - ) : RecyclerView.Adapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): WeeklyMemberViewHolder = WeeklyMemberViewHolder( - FrameLayout(parent.context).apply { - layoutParams = RecyclerView.LayoutParams(tableWidth, rowHeight) - }, - ) - - override fun getItemCount(): Int = model.displayedMembers.size - - override fun onBindViewHolder(holder: WeeklyMemberViewHolder, position: Int) { - val member = model.displayedMembers[position] - holder.container.removeAllViews() - holder.container.addView( - memberRow( - member = member, - rank = model.scoreRanks[member.uid], - gunsmokeWeek = model.report.isGunsmokeWeek, - cutlines = model.cutlines, - isEditing = isEditing, - zoneId = model.zone, - ), - FrameLayout.LayoutParams(tableWidth, rowHeight), + headerAdapter.view = null + stateAdapter.view = StateViews.empty( + this, + R.drawable.ic_calendar, + getString(R.string.no_weekly_data_title), + getString(R.string.no_weekly_data), ) + } else { + stateAdapter.view = null + headerAdapter.view = weeklyTable.headerRow(report) + stickyHeader.addView(weeklyTable.headerRow(report)) } - - override fun onViewRecycled(holder: WeeklyMemberViewHolder) { - holder.container.removeAllViews() - super.onViewRecycled(holder) - - } - } - - private class WeeklyMemberViewHolder( - val container: FrameLayout, - ) : RecyclerView.ViewHolder(container) - - private fun headerRow(report: WeeklyReportBuilder.Report) = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - if (report.isGunsmokeWeek) { - addView(gridCell(getString(R.string.rank), RANK_WIDTH, HEADER_HEIGHT, header = true)) - } - addView(gridCell(getString(R.string.member), MEMBER_WIDTH, HEADER_HEIGHT, header = true)) - report.days.forEach { - addView(gridCell(it.format(DAY), DAILY_WIDTH, HEADER_HEIGHT, header = true)) - } - addView(gridCell(getString(R.string.total), DAILY_WIDTH, HEADER_HEIGHT, header = true)) + rowsAdapter.submit(next.displayedMembers) + renderBottom(next) + updateTopBar() + list.post(::updateStickyHeader) } - private fun memberRow( - member: WeeklyReportBuilder.MemberRow, - rank: Int?, - gunsmokeWeek: Boolean, - cutlines: WeeklyCutlines, - isEditing: Boolean, - zoneId: ZoneId, - ) = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - val rowHeight = metricGroupHeight(gunsmokeWeek) - if (gunsmokeWeek) { - addView(gridCell(rank?.toString().orEmpty(), RANK_WIDTH, rowHeight)) - } - addView(gridCell("${member.name}\n#${member.uid}", MEMBER_WIDTH, rowHeight, textSize = 15f)) - member.days.forEach { cell -> - addView( - if (isEditing) { - editableDailyMetricGroup(member.uid, cell, gunsmokeWeek) - } else { - dailyMetricGroup( - memberName = member.name, - cell = cell, - gunsmokeWeek = gunsmokeWeek, - dayClosed = !PlatoonPeriods.periodStartInstant( - cell.gameDay.plusDays(1), - zoneId, - ).isAfter(Instant.now()), - cutlines = cutlines, - ) - }, - ) + private fun updateStickyHeader() { + val manager = list.layoutManager as? LinearLayoutManager ?: return + if (headerAdapter.view == null) { + stickyHeader.visibility = View.GONE + return } - addView(totalMetricGroup(member.name, member, gunsmokeWeek, cutlines)) - } - - private fun editableDailyMetricGroup( - uid: Long, - cell: WeeklyReportBuilder.DayCell, - gunsmokeWeek: Boolean, - ): LinearLayout { - val key = CellKey(uid, cell.gameDay) - val draft = requireNotNull(editDraft[key]) { "Missing weekly edit draft for $key" } - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - val groupHeight = metricGroupHeight(gunsmokeWeek) - layoutParams = LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(groupHeight)) - addView( - editableNumberCell( - label = getString(R.string.merit_short), - value = draft.merit, - width = DAILY_WIDTH, - ) { - draft.merit = it - draft.meritDirty = true - }, - LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT)), - ) - if (gunsmokeWeek) { - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - addView( - editableNumberCell( - label = getString(R.string.point_short), - value = draft.score, - width = DAILY_WIDTH / 2, - ) { - draft.score = it - draft.scoreDirty = true - }, - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) + val headerPosition = topAdapter.itemCount + val lastRowPosition = headerPosition + rowsAdapter.itemCount + val first = manager.findFirstVisibleItemPosition() + val headerTop = manager.findViewByPosition(headerPosition)?.top ?: 0 + val scrolledPast = first > headerPosition || (first == headerPosition && headerTop < 0) + stickyHeader.visibility = if (scrolledPast && first <= lastRowPosition) View.VISIBLE else View.GONE + } + + private fun buildTop(next: RenderModel): View { + val report = next.report + val editing = editingPeriod == report.periodStart + return column { + setPadding(dp(16), 0, dp(16), dp(12)) + addView(PlatoonProfileSelector.card(this@WeeklyReportActivity, compact = true), lp(top = 4)) + addView(weekNavigator(next, editing), lp(top = 12)) + if (next.restoredHistory) { + addView(panel(emphasized = true, padding = 12) { + addView(text(getString(R.string.weekly_table_history_live_notice), TextRole.SECONDARY) { + setTextColor(getColor(R.color.accent_text)) + }, lp()) addView( - editableNumberCell( - label = getString(R.string.attempt_short), - value = draft.attempts, - width = DAILY_WIDTH / 2, - ) { - draft.attempts = it - draft.attemptsDirty = true + button(getString(R.string.show_live_table), ModernUi.ControlRole.SECONDARY) { + showLiveTable(report.periodStart) }, - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), + lp(WRAP, WRAP, top = 8), ) - }, LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT))) + }, lp(top = 8)) } - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - addView( - editableStatusCell( - getString(R.string.login_short), - draft.attended, - DAILY_WIDTH / 2, - ) { value -> - draft.attended = value - draft.attendedDirty = true - }, - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - addView( - editableStatusCell( - getString(R.string.patrol_short), - draft.dailyPatrol, - DAILY_WIDTH / 2, - ) { value -> - draft.dailyPatrol = value - draft.dailyPatrolDirty = true - }, - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - }, LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT))) - } - } - - private fun editableNumberCell( - label: String, - value: String, - width: Int, - onChange: (String) -> Unit, - ) = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER - setPadding(dp(2), 0, dp(2), 0) - background = gridBackground() - layoutParams = LinearLayout.LayoutParams(dp(width), dp(METRIC_HEIGHT)) - addView(TextView(context).apply { - text = label - textSize = 8f - gravity = Gravity.CENTER - }, LinearLayout.LayoutParams(matchParent(), 0, 0.38f)) - addView(EditText(context).apply { - setText(value) - textSize = 11f - gravity = Gravity.CENTER - inputType = InputType.TYPE_CLASS_NUMBER - setSingleLine(true) - setPadding(0, 0, 0, 0) - background = editableFieldBackground() - selectAll() - addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged( - text: CharSequence?, - start: Int, - count: Int, - after: Int, - ) = Unit - - override fun onTextChanged( - text: CharSequence?, - start: Int, - before: Int, - count: Int, - ) = onChange(text?.toString().orEmpty()) - - override fun afterTextChanged(text: Editable?) = Unit - }) - }, LinearLayout.LayoutParams(matchParent(), 0, 0.62f).apply { - setMargins(dp(3), 0, dp(3), dp(2)) - }) - } - - private fun editableStatusCell( - label: String, - initialValue: Boolean?, - width: Int, - onChange: (Boolean?) -> Unit, - ) = Button(this).apply { - var value = initialValue - isAllCaps = false - textSize = 9f - setPadding(0, 0, 0, 0) - background = editableFieldBackground() - layoutParams = LinearLayout.LayoutParams(dp(width), dp(METRIC_HEIGHT)) - - fun update() { - text = statusText(label, editableActivityMark(value)) - } - update() - setOnClickListener { - value = when (value) { - null -> true - true -> false - false -> null + if (editing) { + addView(panel(emphasized = true, padding = 12) { + addView(row { + addView(icon(R.drawable.ic_edit, R.color.accent_text, sizeDp = 20), lp(20, 20, end = 8)) + addView(text(getString(R.string.weekly_edit_banner), TextRole.SECONDARY) { + setTextColor(getColor(R.color.accent_text)) + }, lp(width = 0, weight = 1f)) + }, lp()) + }, lp(top = 8)) + } else if (report.members.isNotEmpty()) { + addView(evidencePanel(report), lp(top = 8)) } - onChange(value) - update() } } - private fun dailyMetricGroup( - memberName: String, - cell: WeeklyReportBuilder.DayCell, - gunsmokeWeek: Boolean, - dayClosed: Boolean, - cutlines: WeeklyCutlines, - ): LinearLayout { - return metricGroup( - memberName = memberName, - explanations = WeeklyEvidenceAnalyzer.Metric.values().associateWith { metric -> - WeeklyEvidenceAnalyzer.explainDaily(cell, metric) - }, - showGunsmokeMetrics = gunsmokeWeek, - merit = metricText( - getString(R.string.merit_short), - WeeklyMetricPresentation.format(cell.meritDelta, cell.meritCertainty), - WeeklyMetricPresentation.warnsBelowCutline( - cell.meritDelta, - cell.meritCertainty, - cutlines::belowDailyMerit, - ), - ), - score = metricText( - getString(R.string.point_short), - if (gunsmokeWeek) { - WeeklyMetricPresentation.format(cell.scoreDelta, cell.scoreCertainty) - } else { - "-" - }, - gunsmokeWeek && WeeklyMetricPresentation.warnsBelowCutline( - cell.scoreDelta, - cell.scoreCertainty, - cutlines::belowDailyScore, - ), - ), - attempts = metricText( - getString(R.string.attempt_short), - if (gunsmokeWeek) { - WeeklyMetricPresentation.format( - cell.attempts, - cell.attemptsCertainty, - missing = if (cell.manualOverride != null) "-" else "?", - ) - } else { - "-" - }, - gunsmokeWeek && WeeklyMetricPresentation.warnsBelowCutline( - cell.attempts, - cell.attemptsCertainty, - cutlines::belowDailyAttempts, - ), - ), - login = statusText( - getString(R.string.login_short), - if (cell.attended == null && cell.evidence != DailyEvidence.MANUAL) { - unknownActivityMark() - } else { - activityMark(cell.attended, cell.observed, dayClosed) - }, - ), - patrol = statusText( - getString(R.string.patrol_short), - if (cell.dailyPatrol == null && cell.evidence != DailyEvidence.MANUAL) { - unknownActivityMark() - } else { - activityMark(cell.dailyPatrol, cell.observed, dayClosed) - }, - ), - ) - } - - private fun totalMetricGroup( - memberName: String, - member: WeeklyReportBuilder.MemberRow, - gunsmokeWeek: Boolean, - cutlines: WeeklyCutlines, - ): LinearLayout { - return metricGroup( - memberName = memberName, - explanations = WeeklyEvidenceAnalyzer.Metric.values().associateWith { metric -> - WeeklyEvidenceAnalyzer.explainTotal(member, metric) - }, - showGunsmokeMetrics = gunsmokeWeek, - merit = metricText( - getString(R.string.merit_short), - WeeklyMetricPresentation.format(member.totalMerit, member.totalMeritCertainty), - WeeklyMetricPresentation.warnsBelowCutline( - member.totalMerit, - member.totalMeritCertainty, - cutlines::belowWeeklyMerit, - ), - ), - score = metricText( - getString(R.string.point_short), - if (gunsmokeWeek) { - WeeklyMetricPresentation.format(member.totalScore, member.totalScoreCertainty) - } else { - "-" - }, - gunsmokeWeek && WeeklyMetricPresentation.warnsBelowCutline( - member.totalScore, - member.totalScoreCertainty, - cutlines::belowWeeklyScore, - ), - ), - attempts = metricText( - getString(R.string.attempt_short), - if (gunsmokeWeek) { - WeeklyMetricPresentation.format( - member.totalAttempts, - member.totalAttemptsCertainty, - ) - } else { - "-" - }, - gunsmokeWeek && WeeklyMetricPresentation.warnsBelowCutline( - member.totalAttempts, - member.totalAttemptsCertainty, - cutlines::belowWeeklyAttempts, - ), - ), - login = metricText( - getString(R.string.login_short), - WeeklyMetricPresentation.format(member.loginDays, member.loginDaysCertainty), - WeeklyMetricPresentation.warnsBelowCutline( - member.loginDays, - member.loginDaysCertainty, - cutlines::belowWeeklyLoginDays, - ), - ), - patrol = metricText( - getString(R.string.patrol_short), - WeeklyMetricPresentation.format(member.patrolDays, member.patrolDaysCertainty), - WeeklyMetricPresentation.warnsBelowCutline( - member.patrolDays, - member.patrolDaysCertainty, - cutlines::belowWeeklyPatrolDays, - ), - ), - ) - } - - private fun metricGroup( - memberName: String, - explanations: Map, - showGunsmokeMetrics: Boolean, - merit: CharSequence, - score: CharSequence, - attempts: CharSequence, - login: CharSequence, - patrol: CharSequence, - ) = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - val groupHeight = metricGroupHeight(showGunsmokeMetrics) - layoutParams = LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(groupHeight)) - addView( - gridCell( - merit, - DAILY_WIDTH, - METRIC_HEIGHT, - textSize = 11f, - onClick = { - showEvidenceExplanation( - memberName, - requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.MERIT]), + private fun weekNavigator(next: RenderModel, editing: Boolean): View { + val report = next.report + val weekType = getString(if (report.isGunsmokeWeek) R.string.gunsmoke_week else R.string.off_week) + return panel(padding = 4) { + addView(row { + addView(iconButton(R.drawable.ic_chevron_left, getString(R.string.previous_week)) { + reportState.showPreviousWeek(report.periodStart) + requestRender() + }.apply { isEnabled = !editing }, lp(48, 48)) + addView(column { + gravity = Gravity.CENTER_HORIZONTAL + minimumHeight = dp(56) + setPadding(dp(4), dp(6), dp(4), dp(6)) + background = selectableRect() + isEnabled = !editing + setOnClickListener { showDatePicker() } + addView(text(weekType, TextRole.LABEL) { + gravity = Gravity.CENTER + setTextColor(getColor(if (report.isGunsmokeWeek) R.color.accent_text else R.color.text_secondary)) + }, lp()) + val range = getString( + R.string.week_range, + Labels.format(Labels.DATE, report.periodStart), + Labels.format(Labels.DATE, report.periodEnd), ) - }, - ), - LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT)), - ) - if (showGunsmokeMetrics) { - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - addView( - gridCell( - score, - DAILY_WIDTH / 2, - METRIC_HEIGHT, - textSize = 10f, - onClick = { - showEvidenceExplanation( - memberName, - requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.SCORE]), - ) - }, - ), - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) + addView(text(range, TextRole.SUBTITLE) { gravity = Gravity.CENTER }, lp()) + addView(text(getString(R.string.week_reset_zone, next.zone.id), TextRole.CAPTION) { + gravity = Gravity.CENTER + }, lp()) + contentDescription = "$weekType. $range. ${getString(R.string.select_week)}" + }, lp(width = 0, weight = 1f)) + addView(iconButton(R.drawable.ic_chevron_next, getString(R.string.next_week)) { + reportState.showNextWeek(report.periodEnd) + requestRender() + }.apply { isEnabled = !editing }, lp(48, 48)) + }, lp()) + if (report.periodStart != next.currentWeekStart && !editing) { addView( - gridCell( - attempts, - DAILY_WIDTH / 2, - METRIC_HEIGHT, - textSize = 10f, - onClick = { - showEvidenceExplanation( - memberName, - requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.ATTEMPTS]), - ) - }, - ), - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - }, LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT))) - } - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - addView( - gridCell( - login, - DAILY_WIDTH / 2, - METRIC_HEIGHT, - textSize = 10f, - onClick = { - showEvidenceExplanation( - memberName, - requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.LOGIN]), - ) - }, - ), - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - addView( - gridCell( - patrol, - DAILY_WIDTH / 2, - METRIC_HEIGHT, - textSize = 10f, - onClick = { - showEvidenceExplanation( - memberName, - requireNotNull(explanations[WeeklyEvidenceAnalyzer.Metric.DAILY_PATROL]), - ) + button(getString(R.string.go_to_current_week), ModernUi.ControlRole.TERTIARY, R.drawable.ic_today) { + reportState.selectDate(PlatoonPeriods.gameDay(Instant.now(), next.zone)) + requestRender() }, - ), - LinearLayout.LayoutParams(0, dp(METRIC_HEIGHT), 1f), - ) - }, LinearLayout.LayoutParams(dp(DAILY_WIDTH), dp(METRIC_HEIGHT))) - } - - private fun metricGroupHeight(showGunsmokeMetrics: Boolean): Int = - METRIC_HEIGHT * if (showGunsmokeMetrics) 3 else 2 - - private fun gridCell( - value: CharSequence, - width: Int, - height: Int, - header: Boolean = false, - textSize: Float = if (header) 12f else 11f, - onClick: (() -> Unit)? = null, - ) = TextView(this).apply { - text = value - gravity = Gravity.CENTER - this.textSize = textSize - setTextColor(getColor(R.color.text_primary)) - if (header) { - setTypeface(typeface, Typeface.BOLD) - } - setPadding(dp(4), dp(2), dp(4), dp(2)) - background = GradientDrawable().apply { - setColor(if (header) getColor(R.color.table_header) else Color.TRANSPARENT) - setStroke(1, getColor(R.color.outline_strong)) - } - layoutParams = LinearLayout.LayoutParams(dp(width), dp(height)) - onClick?.let { - isClickable = true - isFocusable = true - setOnClickListener { it() } + lp(WRAP, WRAP).apply { gravity = Gravity.CENTER_HORIZONTAL }, + ) + } } } - private fun addEvidenceHealthPanel(report: WeeklyReportBuilder.Report) { + private fun evidencePanel(report: WeeklyReportBuilder.Report): View { val health = WeeklyEvidenceAnalyzer.health(report) - val statusColor = if (health.isComplete) R.color.success_text else R.color.warning_text - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - background = GradientDrawable().apply { - setColor(getColor(R.color.surface)) - cornerRadius = dp(14).toFloat() - } - addView(View(context).apply { - setBackgroundColor(getColor(statusColor)) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(dp(3), ViewGroup.LayoutParams.MATCH_PARENT)) - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(12), dp(8), dp(12), dp(8)) - addView(TextView(context).apply { - text = getString(R.string.evidence_health_title) - textSize = 14f - setTypeface(typeface, Typeface.BOLD) - setTextColor(getColor(statusColor)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString( - R.string.evidence_health_summary, - health.observedDays, - health.totalDays, - health.exactMetrics, - health.lowerBoundMetrics, - health.unknownMetrics, - health.directLoginDays, - health.directPatrolDays, - health.closingBoundaries, - ) - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(2), 0, 0) - }, matchWidth()) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(6) }) - } - - private fun showWeeklyEvidenceGuide(isGunsmokeWeek: Boolean) { - fun guideRow(symbol: String, description: CharSequence): View = - LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(TextView(context).apply { - text = symbol - textSize = if (symbol.length <= 2) 20f else 15f - gravity = Gravity.CENTER - setTypeface(typeface, Typeface.BOLD) - setTextColor(getColor(R.color.accent_text)) - background = ModernUi.panelBackground(context, emphasized = true) - setPadding(dp(8), dp(8), dp(8), dp(8)) - 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 { - text = description - textSize = 14f - setTextColor(getColor(R.color.text_primary)) - setLineSpacing(0f, 1.12f) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - } - val guide = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(20), dp(8), dp(20), dp(12)) - val rows = listOf( - "✓" to getString(R.string.evidence_guide_exact), - "≥" to getString(R.string.evidence_guide_minimum), - "?" to getString(R.string.evidence_guide_unknown), - (if (isGunsmokeWeek) "TOTAL" else "0 / 50 / 90") to getString( - if (isGunsmokeWeek) { - R.string.evidence_guide_gunsmoke_total - } else { - R.string.evidence_guide_standard_allocation - }, + return panel(padding = 4) { + addView(navigationRow( + title = getString(if (health.isComplete) R.string.evidence_complete else R.string.evidence_incomplete), + detail = getString( + R.string.evidence_health_compact, + health.observedDays, + health.totalDays, + health.exactMetrics, + health.lowerBoundMetrics, + health.unknownMetrics, ), - ) - rows.forEachIndexed { index, (symbol, explanation) -> - addView(guideRow(symbol, explanation), LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { if (index > 0) topMargin = dp(12) }) - } - addView(TextView(context).apply { - text = getString(R.string.evidence_guide_gap_warning) - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setLineSpacing(0f, 1.12f) - setPadding(0, dp(16), 0, 0) - }, matchWidth()) + icon = if (health.isComplete) R.drawable.ic_check_circle else R.drawable.ic_warning, + trailing = getString(R.string.how_it_works), + ) { WeeklyDialogs.showGuide(this@WeeklyReportActivity, report.isGunsmokeWeek) }, lp()) + } + } + + private fun renderBottom(next: RenderModel) { + bottomSections.removeAllViews() + if (editingPeriod == next.report.periodStart) return + renderMembershipEvents(next) + bottomSections.addView(sectionHeader(getString(R.string.notes)), lp()) + val notesByDay = next.notes.groupBy(WeeklyNote::gameDay) + if (notesByDay.isEmpty()) { + bottomSections.addView(text(getString(R.string.no_notes), TextRole.SECONDARY) { + setPadding(dp(4), 0, dp(4), dp(8)) + }, lp()) + } else { + bottomSections.addView(panel(padding = 8) { + next.report.days.filter { it in notesByDay }.forEachIndexed { index, day -> + if (index > 0) addView(divider(), lp(top = 4, bottom = 4)) + addView(text(Labels.dayHeader(context, day), TextRole.LABEL) { + setPadding(dp(8), dp(4), dp(8), 0) + }, lp()) + notesByDay[day].orEmpty().forEach { note -> addView(noteRow(note), lp()) } + } + }, lp()) } - AlertDialog.Builder(this) - .setTitle(R.string.weekly_evidence_guide) - .setView(ScrollView(this).apply { addView(guide, matchWidth()) }) - .setPositiveButton(android.R.string.ok, null) - .showModern() + bottomSections.addView(noteComposerPanel(next.report), lp(top = 8)) } - private fun showEvidenceExplanation( - memberName: String, - explanation: WeeklyEvidenceAnalyzer.Explanation, - ) { - val target = explanation.gameDay?.format(DATE) ?: getString(R.string.total) - val facts = explanation.facts - .ifEmpty { listOf(WeeklyEvidenceAnalyzer.Fact.NO_OBSERVATION) } - .joinToString("\n") { "\u2022 " + getString(evidenceFactString(it)) } - AlertDialog.Builder(this) - .setTitle( - getString( - R.string.evidence_explanation_title, - memberName, - target, - metricLabel(explanation.metric), - ), - ) - .setMessage( - getString( - R.string.evidence_explanation_message, - getString( - when (explanation.certainty) { - MetricCertainty.EXACT -> R.string.evidence_certainty_exact - MetricCertainty.LOWER_BOUND -> R.string.evidence_certainty_lower_bound - MetricCertainty.UNKNOWN -> R.string.evidence_certainty_unknown - }, - ), - facts, - ), - ) - .setPositiveButton(android.R.string.ok, null) - .show() - } - - private fun metricLabel(metric: WeeklyEvidenceAnalyzer.Metric): String = getString( - when (metric) { - WeeklyEvidenceAnalyzer.Metric.MERIT -> R.string.merit_short - WeeklyEvidenceAnalyzer.Metric.SCORE -> R.string.point_short - WeeklyEvidenceAnalyzer.Metric.ATTEMPTS -> R.string.attempt_short - WeeklyEvidenceAnalyzer.Metric.LOGIN -> R.string.login_short - WeeklyEvidenceAnalyzer.Metric.DAILY_PATROL -> R.string.patrol_short - }, - ) - - private fun evidenceFactString(fact: WeeklyEvidenceAnalyzer.Fact): Int = when (fact) { - WeeklyEvidenceAnalyzer.Fact.MANUAL_OVERRIDE -> R.string.evidence_fact_manual - WeeklyEvidenceAnalyzer.Fact.EXACT_CLOSING_BOUNDARY -> R.string.evidence_fact_closing - WeeklyEvidenceAnalyzer.Fact.FINAL_GUNSMOKE_SCORE -> R.string.evidence_fact_final_score - WeeklyEvidenceAnalyzer.Fact.EXACT_DAILY_PATROL_EVENT -> R.string.evidence_fact_patrol - WeeklyEvidenceAnalyzer.Fact.LOGIN_TIMESTAMP -> R.string.evidence_fact_login - WeeklyEvidenceAnalyzer.Fact.SOLVER_CONSENSUS -> R.string.evidence_fact_solver - WeeklyEvidenceAnalyzer.Fact.DAILY_CAP_REACHED -> R.string.evidence_fact_daily_cap - WeeklyEvidenceAnalyzer.Fact.WEEKLY_CAP_REACHED -> R.string.evidence_fact_weekly_cap - WeeklyEvidenceAnalyzer.Fact.ALL_DAYS_EXACT -> R.string.evidence_fact_all_exact - WeeklyEvidenceAnalyzer.Fact.CONFIRMED_LOWER_BOUND -> R.string.evidence_fact_lower_bound - WeeklyEvidenceAnalyzer.Fact.NO_OBSERVATION -> R.string.evidence_fact_no_observation - WeeklyEvidenceAnalyzer.Fact.INCOMPLETE_BOUNDARY -> R.string.evidence_fact_incomplete_boundary - WeeklyEvidenceAnalyzer.Fact.PARTIAL_DAY -> R.string.evidence_fact_partial - WeeklyEvidenceAnalyzer.Fact.SPARSE_INFERENCE -> R.string.evidence_fact_sparse - WeeklyEvidenceAnalyzer.Fact.AMBIGUOUS_ALLOCATION -> R.string.evidence_fact_ambiguous - } - - private fun metricText(label: String, value: String, highlighted: Boolean): CharSequence { - val result = SpannableString("$label\n$value") - if (highlighted) { - result.setSpan( - ForegroundColorSpan(getColor(R.color.cutline_mark)), - label.length + 1, - result.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + private fun renderMembershipEvents(next: RenderModel) { + val events = MembershipEventPresentation.deduplicate(next.events) + bottomSections.addView(sectionHeader(getString(R.string.join_withdraw)), lp()) + if (events.isEmpty()) { + bottomSections.addView(text(getString(R.string.no_membership_events), TextRole.SECONDARY) { + setPadding(dp(4), 0, dp(4), dp(8)) + }, lp()) + return + } + val byDay = events.groupBy { MembershipEventPresentation.calendarDate(it, next.zone) } + val ordered: List = next.report.days.filter { it in byDay } + + if (null in byDay) listOf(null) else emptyList() + bottomSections.addView(panel(padding = 8) { + ordered.forEachIndexed { index, day -> + if (index > 0) addView(divider(), lp(top = 4, bottom = 4)) + addView(text(day?.let { Labels.dayHeader(context, it) } ?: getString(R.string.unknown_date), TextRole.LABEL) { + setPadding(dp(8), dp(4), dp(8), 0) + }, lp()) + byDay[day].orEmpty().forEach { event -> addView(eventRow(event, next), lp()) } + } + }, lp()) + } + + private fun eventRow(event: MemberEvent, next: RenderModel): View { + val joined = event.type == MemberEventType.JOINED || event.type == MemberEventType.REJOINED + val label = getString(if (joined) R.string.join_label else R.string.withdraw_label) + val name = next.namesByUid[event.uid].orEmpty().ifBlank { event.note } + val identity = name.takeIf(String::isNotBlank)?.let { "$it (#${event.uid})" } ?: "#${event.uid}" + val time = MembershipEventPresentation.timePrefix(event, next.zone) + return row { + minimumHeight = dp(40) + setPadding(dp(8), 0, dp(8), 0) + isFocusable = true + contentDescription = listOfNotNull(label, time, identity).joinToString(". ") + addView(icon( + if (joined) R.drawable.ic_arrow_down else R.drawable.ic_arrow_up, + if (joined) R.color.success_mark else R.color.failure_mark, + sizeDp = 18, + ), lp(18, 18, end = 8)) + addView(text(label, TextRole.LABEL) { + setTextColor(getColor(if (joined) R.color.success_text else R.color.destructive_action)) + minWidth = dp(44) + }, lp(WRAP, WRAP, end = 8)) + addView(text(listOfNotNull(time, identity).joinToString(" "), TextRole.BODY) { + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + }, lp(width = 0, weight = 1f)) + } + } + + private fun noteRow(note: WeeklyNote): View = row { + minimumHeight = dp(48) + setPadding(dp(8), 0, 0, 0) + addView(text(note.text, TextRole.BODY) { setTextIsSelectable(true) }, lp(width = 0, weight = 1f)) + if (!note.isAutomatic) { + addView( + iconButton(R.drawable.ic_delete, getString(R.string.delete_note_description, note.text.take(40)), R.color.text_secondary) { + confirmNoteDeletion(note) + }, + lp(48, 48), ) } - return result } - private fun statusText(label: String, mark: ActivityMark): CharSequence { - val result = SpannableString("$label\n${mark.symbol}") - mark.color?.let { color -> - result.setSpan( - ForegroundColorSpan(color), - label.length + 1, - result.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, - ) + private fun buildNoteComposer() { + noteInput = textField(getString(R.string.note_hint), id = R.id.weekly_note_input, singleLine = false, maxLength = MAX_NOTE_LENGTH) + noteDayButton = text(null, TextRole.BODY) { + gravity = Gravity.CENTER_VERTICAL + minHeight = dp(48) + setPadding(dp(12), 0, dp(8), 0) + background = selectableRect() + setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_today, 0, R.drawable.ic_expand_more, 0) + compoundDrawablePadding = dp(8) + compoundDrawableTintList = android.content.res.ColorStateList.valueOf(getColor(R.color.text_secondary)) } - return result } - private fun activityMark(value: Boolean?, observed: Boolean, dayClosed: Boolean): ActivityMark = - when { - value == true -> ActivityMark("\u2713", getColor(R.color.success_mark)) - !observed -> ActivityMark("-", null) - value == false && dayClosed -> ActivityMark("\u00d7", getColor(R.color.failure_mark)) - value == false -> ActivityMark("-", null) - else -> ActivityMark("?", null) + private fun noteComposerPanel(report: WeeklyReportBuilder.Report): View { + val today = model?.let { PlatoonPeriods.gameDay(Instant.now(), it.zone) } + if (noteDay !in report.days) noteDay = today?.takeIf { it in report.days } ?: report.days.first() + updateNoteDay() + noteDayButton.setOnClickListener { + Dialogs.choose( + this, + getString(R.string.note_day), + report.days.map { Labels.dayHeader(this, it) }, + report.days.indexOf(noteDay), + ) { + noteDay = report.days[it] + updateNoteDay() + } } + (noteInput.parent as? ViewGroup)?.removeView(noteInput) + (noteDayButton.parent as? ViewGroup)?.removeView(noteDayButton) + return panel(padding = 12) { + addView(text(getString(R.string.add_note), TextRole.SUBTITLE), lp()) + addView(noteDayButton, lp(top = 8)) + addView(noteInput, lp(top = 8)) + addView(button(getString(R.string.add_note), ModernUi.ControlRole.PRIMARY) { addNote(report) }, lp(top = 8)) + } + } - private fun editableActivityMark(value: Boolean?): ActivityMark = when (value) { - true -> ActivityMark("\u2713", getColor(R.color.success_mark)) - false -> ActivityMark("\u00d7", getColor(R.color.failure_mark)) - null -> ActivityMark("-", null) + private fun updateNoteDay() { + val day = noteDay ?: return + noteDayButton.text = Labels.dayHeader(this, day) + noteDayButton.contentDescription = getString(R.string.note_day) + ": " + noteDayButton.text } - private fun unknownActivityMark() = ActivityMark("?", getColor(R.color.warning_mark)) + // endregion - private fun showManualEditWarning(report: WeeklyReportBuilder.Report) { - AlertDialog.Builder(this) - .setTitle(R.string.manual_edit_warning_title) - .setMessage(R.string.manual_edit_warning) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.confirm) { _, _ -> beginWeeklyEditing(report) } - .show() - } + // region Top bar and actions - private fun beginWeeklyEditing(report: WeeklyReportBuilder.Report) { - editingPeriodStart = report.periodStart - editDraft.clear() - report.members.forEach { member -> - member.days.forEach { cell -> - editDraft[CellKey(member.uid, cell.gameDay)] = EditableCell( - uid = member.uid, - gameDay = cell.gameDay, - merit = cell.meritDelta?.toString().orEmpty(), - score = cell.scoreDelta?.toString().orEmpty(), - attempts = cell.attempts?.toString().orEmpty(), - attended = cell.attended, - dailyPatrol = cell.dailyPatrol, - existingOverride = cell.manualOverride, + private fun updateTopBar() { + val current = model + val editing = current != null && editingPeriod == current.report.periodStart + editingBack.isEnabled = editing + val bar = screen.topBar + if (editing) { + bar.setNavigationIcon(R.drawable.ic_close, getString(R.string.cancel_editing)) + .setNavigation(::cancelEditing) + .setTitle( + getString(R.string.edit_weekly_table_title), + resources.getQuantityString(R.plurals.edited_cells, edits.size, edits.size), ) - } - } - requestRender() - } - - private fun saveWeeklyEdits(report: WeeklyReportBuilder.Report) { - if (editingPeriodStart != report.periodStart) { - cancelWeeklyEditing() + .setActions(listOf( + BarAction(R.drawable.ic_check, getString(R.string.save_weekly_edits), edits.isNotEmpty() && !work.isBusy(SAVE)) { + saveEdits() + }, + )) return } - val overrides = runCatching { - editDraft.values.mapNotNull { draft -> - if (draft.existingOverride == null && !draft.isDirty) return@mapNotNull null - WeeklyCellOverride( - uid = draft.uid, - periodStart = report.periodStart, - gameDay = draft.gameDay, - meritDelta = if (draft.meritDirty) { - parseNonNegativeLong(draft.merit) - } else { - draft.existingOverride?.meritDelta - }, - scoreDelta = if (!report.isGunsmokeWeek) { - null - } else if (draft.scoreDirty) { - parseNonNegativeLong(draft.score) - } else { - draft.existingOverride?.scoreDelta + val hasMembers = current?.report?.members?.isNotEmpty() == true + val historyCount = current?.historyCount ?: 0 + bar.setNavigation(null) + .setTitle(getString(R.string.weekly_table)) + .setActions( + listOf( + BarAction(R.drawable.ic_share, getString(R.string.share_weekly_table), hasMembers) { share() }, + BarAction(R.drawable.ic_edit, getString(R.string.edit_weekly_table), hasMembers) { confirmEditing() }, + ), + overflow = listOf( + MenuEntry(getString(R.string.weekly_table_history_with_count, historyCount), historyCount > 0) { + current?.let { openHistory(it.report.periodStart) } }, - attempts = if (!report.isGunsmokeWeek) { - null - } else if (draft.attemptsDirty) { - parseAttempts(draft.attempts) - } else { - draft.existingOverride?.attempts + MenuEntry(getString(R.string.export_weekly_csv), hasMembers) { exportWeek() }, + MenuEntry(getString(R.string.copy_weekly_csv), hasMembers) { copyWeek() }, + MenuEntry(getString(R.string.export_all_weekly_tables)) { exportAll() }, + MenuEntry(getString(R.string.edit_member_order)) { + startActivity(Intent(this, MemberOrderActivity::class.java)) }, - attended = if (draft.attendedDirty) { - draft.attended - } else { - draft.existingOverride?.attended + MenuEntry(getString(R.string.weekly_table_settings)) { + startActivity(Intent(this, WeeklySettingsActivity::class.java)) }, - dailyPatrol = if (draft.dailyPatrolDirty) { - draft.dailyPatrol - } else { - draft.existingOverride?.dailyPatrol + MenuEntry(getString(R.string.weekly_evidence_guide)) { + WeeklyDialogs.showGuide(this, current?.report?.isGunsmokeWeek == true) }, - ).takeIf { it.hasAnyValue() } - } - }.getOrElse { - TransientMessage.show(this, R.string.invalid_weekly_edit, Toast.LENGTH_LONG) - return - } - repository.replaceWeeklyOverrides(report.periodStart.toEpochDay(), overrides) - cancelWeeklyEditing() - TransientMessage.show(this, R.string.weekly_edits_saved) - requestRender() - } - - private fun cancelWeeklyEditing() { - editingPeriodStart = null - editDraft.clear() + ), + ) } - private fun parseNonNegativeLong(value: String): Long? { - if (value.isBlank()) return null - return requireNotNull(value.toLongOrNull()).also { require(it >= 0) } + private fun openHistory(periodStart: LocalDate) { + startActivity( + Intent(this, WeeklyTableHistoryActivity::class.java) + .putExtra(WeeklyTableHistoryActivity.EXTRA_PERIOD_START, periodStart.toEpochDay()), + ) } - private fun parseAttempts(value: String): Int? { - if (value.isBlank()) return null - return requireNotNull(value.toIntOrNull()).also { require(it in 0..3) } + private fun showLiveTable(periodStart: LocalDate) { + work.run(task = { repository.showLiveWeeklyReport(periodStart) }) { requestRender() } } private fun showDatePicker() { + val day = reportState.referenceDay DatePickerDialog( this, - { _, year, month, day -> - reportState.selectDate(LocalDate.of(year, month + 1, day)) + { _, year, month, dayOfMonth -> + reportState.selectDate(LocalDate.of(year, month + 1, dayOfMonth)) requestRender() }, - reportState.referenceDay.year, - reportState.referenceDay.monthValue - 1, - reportState.referenceDay.dayOfMonth, + day.year, + day.monthValue - 1, + day.dayOfMonth, ).show() } - private fun copyWeeklyCsv(report: WeeklyReportBuilder.Report) { - getSystemService(ClipboardManager::class.java).setPrimaryClip( - ClipData.newPlainText( - getString(R.string.weekly_csv_clipboard_label), - WeeklyReportCsv.format(report), - ), - ) - TransientMessage.show(this, R.string.weekly_csv_copied) - } - - private fun addMembershipEvents( - report: WeeklyReportBuilder.Report, - events: List, - namesByUid: Map, - zoneId: ZoneId, - ) { - body.addView(TextView(this).apply { - text = getString(R.string.join_withdraw) - textSize = 21f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(16), 0, dp(4)) - }, matchWidth()) - val membershipEvents = MembershipEventPresentation.deduplicate(events) - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(membershipHeader(getString(R.string.join_label)), weightedCell()) - addView(membershipHeader(getString(R.string.withdraw_label)), weightedCell()) - }, matchWidth()) - - val datedEvents = membershipEvents - .mapNotNull { event -> - MembershipEventPresentation.calendarDate(event, zoneId)?.let { it to event } + private fun onCellTapped(member: WeeklyReportBuilder.MemberRow, cell: WeeklyReportBuilder.DayCell?) { + val current = model ?: return + val weeklyTable = table ?: return + val dayLabel = cell?.let { Labels.dayHeader(this, it.gameDay) } ?: getString(R.string.total) + val title = getString(R.string.weekly_cell_title, member.name, dayLabel) + if (editingPeriod == current.report.periodStart) { + if (cell == null) { + Feedback.show(this, R.string.total_not_editable) + return } - .groupBy({ it.first }, { it.second }) - report.days.forEach { day -> - val dayEvents = datedEvents[day].orEmpty() - if (dayEvents.isEmpty()) return@forEach - body.addView(TextView(this).apply { - text = day.format(DATE) - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(8), 0, dp(2)) - }, matchWidth()) - addMembershipEventRow(dayEvents, namesByUid, zoneId) - } - - val unknownEvents = membershipEvents.filter { - MembershipEventPresentation.calendarDate(it, zoneId) == null + val key = member.uid to cell.gameDay + WeeklyDialogs.showEdit(this, title, current.report.isGunsmokeWeek, member.uid, cell, edits[key]) { edit -> + if (edit.isEmpty) edits.remove(key) else edits[key] = edit + rowsAdapter.notifyItemChanged(current.displayedMembers.indexOfFirst { it.uid == member.uid }) + updateTopBar() + } + return } - if (unknownEvents.isNotEmpty()) { - body.addView(TextView(this).apply { - text = getString(R.string.unknown_date) - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(8), 0, dp(2)) - }, matchWidth()) - addMembershipEventRow(unknownEvents, namesByUid, zoneId) + val explanations = WeeklyEvidenceAnalyzer.Metric.entries.associateWith { metric -> + if (cell == null) { + WeeklyEvidenceAnalyzer.explainTotal(member, metric) + } else { + WeeklyEvidenceAnalyzer.explainDaily(cell, metric) + } } + WeeklyDialogs.showEvidence( + this, + title, + current.report.isGunsmokeWeek, + cell?.let { weeklyTable.daily(it) } ?: weeklyTable.total(member), + explanations, + ) } - private fun addMembershipEventRow( - events: List, - namesByUid: Map, - zoneId: ZoneId, - ) { - val joins = events.filter { - it.type == MemberEventType.JOINED || it.type == MemberEventType.REJOINED + private fun confirmEditing() { + val current = model ?: return + Dialogs.confirm(this, R.string.manual_edit_warning_title, R.string.manual_edit_warning, R.string.start_editing) { + editingPeriod = current.report.periodStart + edits.clear() + render(current) } - val withdrawals = events.filter { - it.type == MemberEventType.LEFT || it.type == MemberEventType.REMOVED - } - body.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - addView(membershipEventCell(joins, namesByUid, zoneId), weightedCell()) - addView(membershipEventCell(withdrawals, namesByUid, zoneId), weightedCell()) - }, matchWidth()) } - private fun membershipHeader(label: String) = TextView(this).apply { - text = label - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(4), dp(8), dp(4)) + private fun cancelEditing() { + if (edits.isEmpty()) { + finishEditing() + return + } + Dialogs.confirm( + this, + R.string.discard_weekly_edits_title, + R.string.discard_weekly_edits, + R.string.discard, + destructive = true, + ) { finishEditing() } + } + + private fun finishEditing() { + editingPeriod = null + edits.clear() + model?.let(::render) ?: updateTopBar() + } + + private fun saveEdits() { + val current = model ?: return + val report = current.report + val existing = report.members.flatMap { member -> member.days.mapNotNull { it.manualOverride } } + when (val merged = WeeklyOverrideEditing.merge(report.periodStart, report.isGunsmokeWeek, existing, edits.values)) { + is WeeklyOverrideEditing.Result.Invalid -> { + val name = current.namesByUid[merged.uid] ?: "#${merged.uid}" + Feedback.show( + this, + getString(R.string.invalid_weekly_edit_cell, name, Labels.dayHeader(this, merged.gameDay)), + long = true, + ) + } + is WeeklyOverrideEditing.Result.Valid -> { + work.run( + key = SAVE, + task = { repository.replaceWeeklyOverrides(report.periodStart.toEpochDay(), merged.overrides) }, + ) { result -> + if (result.isSuccess) { + editingPeriod = null + edits.clear() + Feedback.show(this, R.string.weekly_edits_saved) + requestRender() + } else { + Feedback.show(this, R.string.save_failed) + } + updateTopBar() + } + updateTopBar() + } + } } - private fun weightedCell() = LinearLayout.LayoutParams(0, wrap(), 1f).apply { - marginEnd = dp(8) + private fun exportWeek() { + val current = model ?: return + pendingExport = current.report.periodStart.toEpochDay().toString() + exportCsv.launch("GF2logger-week-${Labels.format(Labels.FILE_DATE, current.report.periodStart)}.csv") } - private fun membershipEventCell( - events: List, - namesByUid: Map, - zoneId: ZoneId, - ) = TextView(this).apply { - text = events.joinToString("\n") { event -> - val eventName = namesByUid[event.uid].orEmpty().ifBlank { event.note } - val identity = eventName.takeIf { it.isNotBlank() } - ?.let { "$it (#${event.uid})" } - ?: "#${event.uid}" - MembershipEventPresentation.timePrefix(event, zoneId) - ?.let { "$it $identity" } - ?: identity + private fun exportAll() { + work.run( + task = { repository.listAllWeeklyReports(GameTimeZonePreferences.get(this, scope.storageId)).isNotEmpty() }, + ) { result -> + if (result.getOrDefault(false)) { + pendingExport = EXPORT_ALL + exportCsv.launch("GF2logger-all-weekly-tables.csv") + } else { + Feedback.show(this, R.string.no_weekly_tables_to_export) + } } - setPadding(0, dp(2), dp(8), dp(4)) } - private fun addNote(note: WeeklyNote) { - body.addView(Button(this).apply { - isAllCaps = false - text = note.text - setOnClickListener { confirmNoteDeletion(note) } - }, matchWidth()) + private fun copyWeek() { + val report = model?.report ?: return + getSystemService(ClipboardManager::class.java).setPrimaryClip( + ClipData.newPlainText(getString(R.string.weekly_csv_clipboard_label), WeeklyReportCsv.format(report)), + ) + Feedback.show(this, R.string.weekly_csv_copied) } - private fun confirmNoteDeletion(note: WeeklyNote) { - AlertDialog.Builder(this) - .setTitle(R.string.delete_note) - .setMessage(R.string.delete_note_confirmation) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.delete) { _, _ -> - if (repository.deleteWeeklyNote(note.id)) { - TransientMessage.show(this, R.string.note_deleted) - requestRender() + private fun share() { + val current = model ?: return + WeeklyDialogs.showShare(this) { privacy, shareAfter -> + Feedback.show(this, R.string.weekly_png_rendering) + work.run( + task = { + WeeklyPngExport.render( + this, + WeeklyShareProjection.build(current.report, current.displayedMembers, current.memberNotesByUid, privacy), + current.report.periodStart, + ) + }, + ) { result -> + val file = result.getOrNull() + when { + file == null -> Feedback.show(this, R.string.weekly_png_failed, long = true) + shareAfter -> WeeklyPngExport.share(this, file) + else -> { + pendingPng = file + exportPng.launch(WeeklyPngExport.exportName(current.report.periodStart)) + } } } - .show() - } - - - private fun showWeeklyShareOptions(model: RenderModel) { - val includeNames = CheckBox(this).apply { - text = getString(R.string.share_include_member_names) - isChecked = true - } - val includeUids = CheckBox(this).apply { - text = getString(R.string.share_include_uids) - isChecked = false } - val includeNotes = CheckBox(this).apply { - text = getString(R.string.share_include_private_notes) - isChecked = false - } - val options = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(20), dp(8), dp(20), 0) - addView(includeNames, matchWidth()) - addView(includeUids, matchWidth()) - addView(includeNotes, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.share_privacy_notice) - textSize = 13f - setPadding(0, dp(8), 0, 0) - }, matchWidth()) - } - fun privacy() = WeeklyShareProjection.Privacy( - includeMemberNames = includeNames.isChecked, - includeUids = includeUids.isChecked, - includePrivateNotes = includeNotes.isChecked, - ) - AlertDialog.Builder(this) - .setTitle(R.string.share_weekly_table) - .setView(options) - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.save_png) { _, _ -> - generateWeeklyPng(model, privacy(), shareAfter = false) - } - .setPositiveButton(R.string.share_png) { _, _ -> - generateWeeklyPng(model, privacy(), shareAfter = true) - } - .show() } - private fun generateWeeklyPng( - model: RenderModel, - privacy: WeeklyShareProjection.Privacy, - shareAfter: Boolean, - ) { - TransientMessage.show(this, R.string.weekly_png_rendering) - workerExecutor.execute { - val result = runCatching { - val document = WeeklyShareProjection.build( - report = model.report, - displayedMembers = model.displayedMembers, - privateNotesByUid = model.memberNotesByUid, - privacy = privacy, - ) - writeWeeklyPng(document, model.report.periodStart) - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - result.fold( - onSuccess = { file -> - if (shareAfter) shareWeeklyPng(file) else saveWeeklyPng(file, model.report.periodStart) - }, - onFailure = { - TransientMessage.show( - this, - R.string.weekly_png_failed, - Toast.LENGTH_LONG, - ) - }, - ) - } + private fun addNote(report: WeeklyReportBuilder.Report) { + val text = noteInput.text.toString().trim() + val day = noteDay ?: return + if (text.isEmpty()) { + noteInput.error = getString(R.string.note_required) + return + } + work.run( + key = SAVE, + task = { repository.addWeeklyNote(report.periodStart.toEpochDay(), day.toEpochDay(), text) }, + ) { result -> + result.fold( + onSuccess = { + noteInput.text = null + Feedback.show(this, R.string.note_added) + requestRender() + }, + onFailure = { error -> + Feedback.show( + this, + if (error is WeeklyNoteLimitException) R.string.weekly_note_limit_reached else R.string.save_failed, + ) + }, + ) } } - private fun writeWeeklyPng( - document: WeeklyShareProjection.Document, - periodStart: LocalDate, - ): File { - val directory = WeeklyPngPendingState.directory(cacheDir).apply { mkdirs() } - require(directory.isDirectory) { "Unable to create weekly share cache" } - revokeAndDeleteOldWeeklyPngs(directory) - val target = WeeklyPngPendingState.newRenderTarget(cacheDir, periodStart) - val temporary = File.createTempFile(".weekly-", ".png", directory) - val bitmap = WeeklyReportPngRenderer.render(document) - try { - FileOutputStream(temporary).use { output -> - check(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, output)) { - "Unable to encode weekly PNG" + private fun confirmNoteDeletion(note: WeeklyNote) { + Dialogs.confirm(this, getString(R.string.delete_note), note.text, getString(R.string.delete), destructive = true) { + work.run(task = { repository.deleteWeeklyNote(note.id) }) { result -> + if (result.getOrDefault(false)) { + Feedback.show(this, R.string.note_deleted) + requestRender() } - output.fd.sync() } - check(temporary.renameTo(target)) { "Unable to publish weekly PNG" } - } finally { - bitmap.recycle() - temporary.delete() } - return target } - private fun revokeAndDeleteOldWeeklyPngs(directory: File) { - directory.listFiles().orEmpty() - .filter(java.io.File::isFile) - .forEach { stale -> - runCatching { - val uri = FileProvider.getUriForFile( - this, - packageName + ".fileprovider", - stale, - ) - revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - stale.delete() - } - } + // endregion - private fun shareWeeklyPng(file: File) { - val uri = FileProvider.getUriForFile( - this, - packageName + ".fileprovider", - file, - ) - val share = Intent(Intent.ACTION_SEND) - .setType("image/png") - .putExtra(Intent.EXTRA_STREAM, uri) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - share.clipData = ClipData.newUri(contentResolver, getString(R.string.share_weekly_table), uri) - startActivity(Intent.createChooser(share, getString(R.string.share_weekly_table))) - } + private inner class RowsAdapter : RecyclerView.Adapter() { + private var members = emptyList() - @Suppress("DEPRECATION") - private fun saveWeeklyPng(file: File, periodStart: LocalDate) { - pendingPng = file - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("image/png") - .putExtra(Intent.EXTRA_TITLE, WeeklyPngPendingState.exportName(periodStart)) - startActivityForResult(intent, REQUEST_EXPORT_WEEKLY_PNG) - } - @Deprecated("Uses the platform document picker without an AndroidX dependency") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == REQUEST_EXPORT_WEEKLY_PNG) { - val source = pendingPng - pendingPng = null - val destination = data?.data - if (resultCode != RESULT_OK || destination == null || source == null) return - val exported = runCatching { - val output = dev.gf2log.app.ui.DocumentAccess.openOutput(contentResolver, destination) - ?: error("Document provider did not open an output stream") - output.use { target -> - source.inputStream().use { input -> input.copyTo(target) } - } - }.isSuccess - source.delete() - TransientMessage.show( - this, - getString( - if (exported) R.string.weekly_png_saved else R.string.weekly_png_failed, - ), - Toast.LENGTH_SHORT, - ) - return + fun submit(next: List) { + members = next + notifyDataSetChanged() } - if (requestCode != REQUEST_EXPORT_WEEKLY) return + override fun getItemCount() = members.size - val content = pendingCsv - pendingCsv = null - val destination = data?.data - if (resultCode != RESULT_OK || destination == null || content == null) return - val exported = runCatching { - val output = dev.gf2log.app.ui.DocumentAccess.openOutput(contentResolver, destination) - ?: error("Document provider did not open an output stream") - output.writer(Charsets.UTF_8).use { it.write(content) } - }.isSuccess - TransientMessage.show( - this, - getString(if (exported) R.string.weekly_csv_exported else R.string.status_export_failed), - Toast.LENGTH_SHORT, - ) - } + override fun getItemViewType(position: Int) = tableGeneration - @Suppress("DEPRECATION") - private fun exportWeeklyCsv(report: WeeklyReportBuilder.Report) { - pendingCsv = WeeklyReportCsv.format(report) - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("text/csv") - .putExtra( - Intent.EXTRA_TITLE, - "GF2logger-week-${report.periodStart.format(FILE_DATE)}.csv", - ) - startActivityForResult(intent, REQUEST_EXPORT_WEEKLY) - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RowHolder = + RowHolder(requireNotNull(table).MemberRow()) - private fun exportAllWeeklyTables() { - workerExecutor.execute { - val content = runCatching { - repository.listAllWeeklyReports(GameTimeZonePreferences.get(this)) - .takeIf(List<*>::isNotEmpty) - ?.let(WeeklyReportCsv::formatAll) - }.getOrNull() - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - if (content == null) { - TransientMessage.show(this, R.string.no_weekly_tables_to_export) - return@runOnUiThread - } - pendingCsv = content - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("text/csv") - .putExtra(Intent.EXTRA_TITLE, "mobileGF2logger-all-weekly-tables.csv") - @Suppress("DEPRECATION") - startActivityForResult(intent, REQUEST_EXPORT_WEEKLY) - } + override fun onBindViewHolder(holder: RowHolder, position: Int) { + val member = members[position] + val current = model ?: return + val dayEdits = edits.values.filter { it.uid == member.uid }.associateBy { it.gameDay } + val rank = current.scoreRanks[member.uid].takeIf { current.report.isGunsmokeWeek } + holder.row.bind(member, rank, dayEdits) { cell -> onCellTapped(member, cell) } } } - private fun addNoteEditor(report: WeeklyReportBuilder.Report) { - val day = Spinner(this).apply { - adapter = ArrayAdapter( - this@WeeklyReportActivity, - android.R.layout.simple_spinner_dropdown_item, - report.days.map { it.format(DATE) }, - ) - } - val note = EditText(this).apply { - hint = getString(R.string.note_hint) - setSingleLine(true) - } - body.addView(day, matchWidth()) - body.addView(note, matchWidth()) - body.addView(Button(this).apply { - text = getString(R.string.add_note) - usePrimaryActionStyle() - setOnClickListener { - val text = note.text.toString().trim() - if (text.isBlank()) return@setOnClickListener - val gameDay = report.days[day.selectedItemPosition] - runCatching { - repository.addWeeklyNote( - report.periodStart.toEpochDay(), - gameDay.toEpochDay(), - text, - ) - }.fold( - onSuccess = { - TransientMessage.show( - this@WeeklyReportActivity, - getString(R.string.saved), - Toast.LENGTH_SHORT, - ) - requestRender() - }, - onFailure = { error -> - TransientMessage.show( - this@WeeklyReportActivity, - getString( - if (error is WeeklyNoteLimitException) { - R.string.weekly_note_limit_reached - } else { - R.string.save_failed - }, - ), - Toast.LENGTH_SHORT, - ) - }, - ) - } - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) + private class RowHolder(val row: WeeklyTable.MemberRow) : RecyclerView.ViewHolder(row.view) + + private fun encodeEdit(edit: WeeklyOverrideEditing.CellEdit) = Bundle().apply { + putLong("uid", edit.uid) + putLong("day", edit.gameDay.toEpochDay()) + edit.merit?.let { putString("merit", it) } + edit.score?.let { putString("score", it) } + edit.attempts?.let { putString("attempts", it) } + edit.attended?.let { putString("attended", it.name) } + edit.dailyPatrol?.let { putString("patrol", it.name) } } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - private fun matchParent() = ViewGroup.LayoutParams.MATCH_PARENT - private fun wrap() = ViewGroup.LayoutParams.WRAP_CONTENT - private fun wrapWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, + private fun decodeEdit(bundle: Bundle) = WeeklyOverrideEditing.CellEdit( + uid = bundle.getLong("uid"), + gameDay = LocalDate.ofEpochDay(bundle.getLong("day")), + merit = bundle.getString("merit"), + score = bundle.getString("score"), + attempts = bundle.getString("attempts"), + attended = bundle.getString("attended")?.let(WeeklyOverrideEditing.Choice::valueOf), + dailyPatrol = bundle.getString("patrol")?.let(WeeklyOverrideEditing.Choice::valueOf), ) private data class RenderModel( @@ -1801,59 +886,19 @@ class WeeklyReportActivity : BaseActivity() { val memberNotesByUid: Map, val historyCount: Int, val restoredHistory: Boolean, + val currentWeekStart: LocalDate, ) - private data class ActivityMark(val symbol: String, val color: Int?) - - private data class CellKey(val uid: Long, val gameDay: LocalDate) - - private data class EditableCell( - val uid: Long, - val gameDay: LocalDate, - var merit: String, - var score: String, - var attempts: String, - var attended: Boolean?, - var dailyPatrol: Boolean?, - val existingOverride: WeeklyCellOverride?, - var meritDirty: Boolean = false, - var scoreDirty: Boolean = false, - var attemptsDirty: Boolean = false, - var attendedDirty: Boolean = false, - var dailyPatrolDirty: Boolean = false, - ) { - val isDirty: Boolean - get() = meritDirty || scoreDirty || attemptsDirty || attendedDirty || dailyPatrolDirty - } - - private fun WeeklyCellOverride.hasAnyValue(): Boolean = - meritDelta != null || scoreDelta != null || attempts != null || - attended != null || dailyPatrol != null - - private fun gridBackground() = GradientDrawable().apply { - setColor(Color.TRANSPARENT) - setStroke(1, getColor(R.color.outline_strong)) - } - - private fun editableFieldBackground() = GradientDrawable().apply { - setColor(getColor(R.color.surface_variant)) - setStroke(dp(1), getColor(R.color.primary)) - cornerRadius = dp(8).toFloat() - } - - companion object { - private val DATE = DateTimeFormatter.ofPattern("yy/MM/dd") - private val DAY = DateTimeFormatter.ofPattern("MM/dd") - private val FILE_DATE = DateTimeFormatter.BASIC_ISO_DATE - private const val REQUEST_EXPORT_WEEKLY = 201 - private const val STATE_REFERENCE_DAY = "weekly.reference_day" - private const val STATE_PENDING_PNG_NAME = "weekly.pending_png_name" - private const val HEADER_HEIGHT = 40 - private const val REQUEST_EXPORT_WEEKLY_PNG = 202 - private const val METRIC_HEIGHT = 36 - private const val RANK_WIDTH = 42 - private const val MEMBER_WIDTH = 120 - private const val DAILY_WIDTH = 128 - private const val MAX_VISIBLE_TABLE_ROWS = 6 + private companion object { + const val RENDER = "render" + const val SAVE = "save" + const val EXPORT_ALL = "all" + const val MAX_NOTE_LENGTH = 1_000 + const val STATE_REFERENCE_DAY = "weekly.reference_day" + const val STATE_PENDING_PNG_NAME = "weekly.pending_png_name" + const val STATE_PENDING_EXPORT = "weekly.pending_export" + const val STATE_EDITING_PERIOD = "weekly.editing_period" + const val STATE_EDITS = "weekly.edits" + const val STATE_NOTE_DAY = "weekly.note_day" } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt index 6491759..8263a50 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt @@ -1,5 +1,6 @@ package dev.gf2log.app +import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color @@ -9,9 +10,12 @@ import dev.gf2log.app.management.WeeklyShareProjection import kotlin.math.min import kotlin.math.sqrt -/** Renders a bounded, privacy-filtered weekly share document without reading UI views. */ +/** + * Renders a bounded, privacy-filtered weekly share document without reading UI views. The image + * always uses the light paper palette so shared tables stay legible in any chat app. + */ object WeeklyReportPngRenderer { - fun render(document: WeeklyShareProjection.Document): Bitmap { + fun render(context: Context, document: WeeklyShareProjection.Document): Bitmap { require(document.headers.size in 3..11) require(document.rows.size <= MAX_ROWS) val memberWidth = 240 @@ -38,26 +42,30 @@ object WeeklyReportPngRenderer { val height = maxOf(1, (naturalHeight * scale).toInt()) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) - canvas.drawColor(Color.WHITE) + canvas.drawColor(PAPER) canvas.scale(scale, scale) val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(27, 47, 78) + color = ACCENT_INK textSize = 30f typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) } val subtitlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(70, 78, 91) + color = MUTED_INK textSize = 18f } val health = document.evidenceHealth canvas.drawText(document.title, PADDING.toFloat(), (PADDING + 32).toFloat(), titlePaint) canvas.drawText(document.subtitle, PADDING.toFloat(), (PADDING + 58).toFloat(), subtitlePaint) canvas.drawText( - "Evidence: " + health.observedDays + "/" + health.totalDays + - " days \u2022 exact " + health.exactMetrics + - " \u2022 minimum " + health.lowerBoundMetrics + - " \u2022 unknown " + health.unknownMetrics, + context.getString( + R.string.png_evidence_summary, + health.observedDays, + health.totalDays, + health.exactMetrics, + health.lowerBoundMetrics, + health.unknownMetrics, + ), PADDING.toFloat(), (PADDING + 84).toFloat(), subtitlePaint, @@ -107,18 +115,18 @@ object WeeklyReportPngRenderer { header: Boolean = false, ) { val fill = Paint().apply { - color = if (header) Color.rgb(218, 228, 244) else Color.WHITE + color = if (header) HEADER else SURFACE style = Paint.Style.FILL } val border = Paint().apply { - color = Color.rgb(122, 132, 146) + color = OUTLINE style = Paint.Style.STROKE strokeWidth = 1f } canvas.drawRect(left.toFloat(), top.toFloat(), (left + width).toFloat(), (top + height).toFloat(), fill) canvas.drawRect(left.toFloat(), top.toFloat(), (left + width).toFloat(), (top + height).toFloat(), border) val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.rgb(28, 32, 38) + color = INK textSize = if (header) 18f else 16f typeface = Typeface.create( Typeface.DEFAULT, @@ -157,6 +165,13 @@ 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 diff --git a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt index 5364cb6..c55e5a3 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt @@ -1,191 +1,159 @@ package dev.gf2log.app -import android.graphics.Typeface import android.os.Bundle import android.text.InputType -import android.view.Gravity -import android.view.ViewGroup -import android.widget.Button -import android.widget.CheckBox +import android.view.View import android.widget.EditText import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast +import androidx.activity.OnBackPressedCallback import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.switchRow +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField +/** Per-Platoon cutlines that highlight weekly cells which are, or may remain, below a target. */ class WeeklySettingsActivity : BaseActivity() { - private lateinit var dailyMerit: ThresholdInput - private lateinit var dailyScore: ThresholdInput - private lateinit var dailyAttempts: ThresholdInput - private lateinit var weeklyMerit: ThresholdInput - private lateinit var weeklyScore: ThresholdInput - private lateinit var weeklyAttempts: ThresholdInput - private lateinit var loginDays: ThresholdInput - private lateinit var patrolDays: ThresholdInput + override val legacyStyling = false + + private lateinit var profileBinding: ActivePlatoonScopeBinding + private lateinit var preferences: WeeklyCutlinePreferences + private lateinit var initial: WeeklyCutlines + private val inputs = mutableListOf() + private val unsavedBack = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = leave() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - title = getString(R.string.weekly_table_settings) - setContentView(buildContentView(WeeklyCutlinePreferences(this).read())) + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope ?: run { + finish() + return + } + preferences = WeeklyCutlinePreferences(this, scope.storageId) + initial = preferences.read() + onBackPressedDispatcher.addCallback(this, unsavedBack) + val screen = Screen(this) + screen.topBar + .setNavigation(::leave) + .setTitle(getString(R.string.weekly_table_settings)) + .setActions(listOf(BarAction(R.drawable.ic_check, getString(R.string.save_settings)) { save() })) + val content = column { + addView(text(getString(R.string.cutline_description), TextRole.SECONDARY), lp(top = 4)) + addView(sectionHeader(getString(R.string.daily_cutlines)), lp()) + addView(panel(padding = 8) { + threshold(R.id.cutline_daily_merit, R.string.merit_cutline, initial.dailyMerit, null) + threshold(R.id.cutline_daily_score, R.string.gunsmoke_score_cutline, initial.dailyGunsmokeScore, null) + threshold(R.id.cutline_daily_attempts, R.string.gunsmoke_attempt_cutline, initial.dailyGunsmokeAttempts?.toLong(), DAILY_ATTEMPTS) + }, lp()) + addView(sectionHeader(getString(R.string.weekly_cutlines)), lp()) + addView(panel(padding = 8) { + threshold(R.id.cutline_weekly_merit, R.string.merit_cutline, initial.weeklyMerit, null) + threshold(R.id.cutline_weekly_score, R.string.gunsmoke_score_cutline, initial.weeklyGunsmokeScore, null) + threshold(R.id.cutline_weekly_attempts, R.string.gunsmoke_attempt_cutline, initial.weeklyGunsmokeAttempts?.toLong(), WEEKLY_ATTEMPTS) + threshold(R.id.cutline_login_days, R.string.weekly_login_cutline, initial.weeklyLoginDays?.toLong(), WEEK_DAYS) + threshold(R.id.cutline_patrol_days, R.string.weekly_patrol_cutline, initial.weeklyPatrolDays?.toLong(), WEEK_DAYS) + }, lp()) + addView(button(getString(R.string.save_settings), ModernUi.ControlRole.PRIMARY) { save() }, lp(top = 16)) + } + screen.setBody(Screen.scrollingBody(this, content)) } - private fun buildContentView(current: WeeklyCutlines): ScrollView { - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(16), dp(16), dp(16)) - - addView(TextView(context).apply { - text = getString(R.string.weekly_table_settings) - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.cutline_description) - setPadding(0, dp(6), 0, dp(12)) - }, matchWidth()) + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) finish() + } - addSectionHeading(R.string.daily_cutlines) - dailyMerit = thresholdInput(R.string.merit_cutline, current.dailyMerit, null) - dailyScore = thresholdInput( - R.string.gunsmoke_score_cutline, - current.dailyGunsmokeScore, - null, - ) - dailyAttempts = thresholdInput( - R.string.gunsmoke_attempt_cutline, - current.dailyGunsmokeAttempts?.toLong(), - 3, - ) + private fun LinearLayout.threshold(id: Int, label: Int, value: Long?, maximum: Int?) { + val input = textField(getString(R.string.cutline_value), id = id, inputType = InputType.TYPE_CLASS_NUMBER).apply { + setText(value?.toString().orEmpty()) + } + val supporting = maximum?.let { getString(R.string.cutline_range, it) } + val field = LabeledField(context, getString(R.string.cutline_value), input, supporting) + val threshold = Threshold(input, field, maximum, value != null) + field.visibility = if (threshold.enabled) View.VISIBLE else View.GONE + addView(switchRow(getString(label), supporting, threshold.enabled) { checked -> + threshold.enabled = checked + field.visibility = if (checked) View.VISIBLE else View.GONE + if (checked) input.requestFocus() + }, lp()) + addView(field, lp(start = 4, end = 4, bottom = 8)) + inputs += threshold + } - addSectionHeading(R.string.weekly_cutlines) - weeklyMerit = thresholdInput(R.string.merit_cutline, current.weeklyMerit, null) - weeklyScore = thresholdInput( - R.string.gunsmoke_score_cutline, - current.weeklyGunsmokeScore, - null, - ) - weeklyAttempts = thresholdInput( - R.string.gunsmoke_attempt_cutline, - current.weeklyGunsmokeAttempts?.toLong(), - 21, - ) - loginDays = thresholdInput( - R.string.weekly_login_cutline, - current.weeklyLoginDays?.toLong(), - 7, - ) - patrolDays = thresholdInput( - R.string.weekly_patrol_cutline, - current.weeklyPatrolDays?.toLong(), - 7, + private fun readValues(): List? { + var valid = true + val values = inputs.map { threshold -> + if (!threshold.enabled) { + threshold.field.showError(null) + return@map null + } + val parsed = threshold.input.text.toString().trim().toLongOrNull() + val ok = parsed != null && parsed >= 0 && threshold.maximum?.let { parsed <= it } != false + threshold.field.showError( + if (ok) null else threshold.maximum?.let { getString(R.string.cutline_range_error, it) } + ?: getString(R.string.cutline_number_error), ) - - addView(Button(context).apply { - text = getString(R.string.save_settings) - usePrimaryActionStyle() - setOnClickListener { save() } - }, matchWidth()) + if (!ok) valid = false + parsed } - return ScrollView(this).apply { addView(container, matchWidth()) } + return values.takeIf { valid } } - private fun LinearLayout.addSectionHeading(label: Int) { - addView(TextView(context).apply { - text = getString(label) - textSize = 20f - setPadding(0, dp(12), 0, dp(4)) - }, matchWidth()) - } + private fun current(values: List) = WeeklyCutlines( + dailyMerit = values[0], + dailyGunsmokeScore = values[1], + dailyGunsmokeAttempts = values[2]?.toInt(), + weeklyMerit = values[3], + weeklyGunsmokeScore = values[4], + weeklyGunsmokeAttempts = values[5]?.toInt(), + weeklyLoginDays = values[6]?.toInt(), + weeklyPatrolDays = values[7]?.toInt(), + ) - private fun LinearLayout.thresholdInput( - label: Int, - value: Long?, - maximum: Int?, - ): ThresholdInput { - val input = ThresholdInput( - checkBox = CheckBox(context).apply { - text = getString(label) - isChecked = value != null - gravity = Gravity.CENTER_VERTICAL - }, - value = EditText(context).apply { - inputType = InputType.TYPE_CLASS_NUMBER - setSingleLine(true) - hint = getString(R.string.cutline_value) - setText(value?.toString().orEmpty()) - isEnabled = value != null - gravity = Gravity.CENTER_VERTICAL - }, - maximum = maximum, - ) - input.checkBox.setOnCheckedChangeListener { _, checked -> - input.value.isEnabled = checked - } - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - isBaselineAligned = false - gravity = Gravity.CENTER_VERTICAL - minimumHeight = dp(56) - addView(input.checkBox, LinearLayout.LayoutParams(0, dp(48), 2f)) - addView(input.value, LinearLayout.LayoutParams(0, dp(48), 1f)) - }, matchWidth()) - return input + private fun save() { + val values = readValues() ?: return + preferences.write(current(values)) + Feedback.show(this, R.string.saved) + finish() } - private fun save() { - val values = listOf( - dailyMerit, - dailyScore, - dailyAttempts, - weeklyMerit, - weeklyScore, - weeklyAttempts, - loginDays, - patrolDays, - ).map { runCatching { it.read() }.getOrNull() } - if (values.any { it == INVALID }) { - TransientMessage.show(this, R.string.invalid_cutline) + private fun leave() { + val values = readValues() + if (values != null && current(values) == initial) { + finish() return } - WeeklyCutlinePreferences(this).write( - WeeklyCutlines( - dailyMerit = values[0], - dailyGunsmokeScore = values[1], - dailyGunsmokeAttempts = values[2]?.toInt(), - weeklyMerit = values[3], - weeklyGunsmokeScore = values[4], - weeklyGunsmokeAttempts = values[5]?.toInt(), - weeklyLoginDays = values[6]?.toInt(), - weeklyPatrolDays = values[7]?.toInt(), - ), + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.unsaved_changes_title) + .setMessage(R.string.unsaved_changes_message) + .setNegativeButton(R.string.discard) { _, _ -> finish() } + .setPositiveButton(R.string.save_settings) { _, _ -> save() }, ) - TransientMessage.show(this, R.string.saved) - finish() - } - - private fun ThresholdInput.read(): Long? { - if (!checkBox.isChecked) return null - val parsed = value.text.toString().toLongOrNull() ?: return INVALID - if (parsed < 0 || maximum?.let { parsed > it } == true) return INVALID - return parsed } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - private fun wrap() = ViewGroup.LayoutParams.WRAP_CONTENT - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - - private data class ThresholdInput( - val checkBox: CheckBox, - val value: EditText, + private class Threshold( + val input: EditText, + val field: LabeledField, val maximum: Int?, + var enabled: Boolean, ) private companion object { - const val INVALID = Long.MIN_VALUE + const val DAILY_ATTEMPTS = 3 + const val WEEKLY_ATTEMPTS = 21 + const val WEEK_DAYS = 7 } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt new file mode 100644 index 0000000..df6bc7c --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt @@ -0,0 +1,404 @@ +package dev.gf2log.app + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.HorizontalScrollView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.ColorRes +import dev.gf2log.app.management.DailyEvidence +import dev.gf2log.app.management.MetricCertainty +import dev.gf2log.app.management.PlatoonPeriods +import dev.gf2log.app.management.WeeklyMetricPresentation +import dev.gf2log.app.management.WeeklyOverrideEditing +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.settings.WeeklyCutlines +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.dpf +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.text +import java.time.Instant +import java.time.ZoneId +import java.util.WeakHashMap + +/** Keeps every row's horizontal scroll position identical so the table scrolls as one grid. */ +internal class HorizontalSync { + private val views = WeakHashMap() + var offset = 0 + private set + + fun register(view: SyncedScrollView) { + views[view] = Unit + } + + fun publish(source: SyncedScrollView, x: Int) { + offset = x + views.keys.forEach { if (it !== source) it.syncTo(x) } + } +} + +internal class SyncedScrollView(context: Context, private val sync: HorizontalSync) : HorizontalScrollView(context) { + private var syncing = false + + init { + isHorizontalScrollBarEnabled = false + overScrollMode = OVER_SCROLL_NEVER + sync.register(this) + } + + override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) { + super.onScrollChanged(l, t, oldl, oldt) + if (!syncing) sync.publish(this, l) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + syncTo(sync.offset) + } + + fun syncTo(x: Int) { + if (scrollX == x) return + syncing = true + scrollTo(x, 0) + syncing = false + } +} + +/** Display-only projection of one weekly cell. */ +internal data class CellDisplay( + val merit: String, + val meritWarn: Boolean, + val score: String?, + val scoreWarn: Boolean, + val attempts: String?, + val attemptsWarn: Boolean, + val login: Mark, + val patrol: Mark, + val edited: Boolean, +) { + data class Mark(val symbol: String, @ColorRes val color: Int, val spoken: Int? = null) +} + +/** + * Builds the weekly grid rows: a frozen member column followed by seven daily cells and the + * weekly total. Values keep the evidence notation from the domain (`≥` minimum, `?` unknown). + */ +internal class WeeklyTable( + private val context: Context, + val isGunsmokeWeek: Boolean, + private val zone: ZoneId, + private val cutlines: WeeklyCutlines, + private val now: Instant = Instant.now(), +) { + val sync = HorizontalSync() + val rowHeight = context.dp(if (isGunsmokeWeek) GUNSMOKE_ROW_HEIGHT else STANDARD_ROW_HEIGHT) + + fun headerRow(report: WeeklyReportBuilder.Report): View = gridRow( + frozen = headerCell(context.getString(R.string.member), null, MEMBER_WIDTH, Gravity.START or Gravity.CENTER_VERTICAL), + cells = report.days.map { day -> + headerCell( + day.dayOfWeek.getDisplayName(java.time.format.TextStyle.SHORT, context.resources.configuration.locales[0]), + "${day.monthValue}/${day.dayOfMonth}", + DAY_WIDTH, + Gravity.CENTER, + ) + } + headerCell(context.getString(R.string.total), null, TOTAL_WIDTH, Gravity.CENTER), + height = context.dp(HEADER_HEIGHT), + ) + + /** One reusable member row; [bind] refreshes it in place for RecyclerView recycling. */ + inner class MemberRow { + private val name = context.text(null, TextRole.BODY) { + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + } + private val detail = context.text(null, TextRole.CAPTION) { maxLines = 1 } + private val rank = context.text(null, TextRole.LABEL) { + gravity = Gravity.CENTER + setTextColor(context.getColor(R.color.accent_text)) + background = GradientDrawable().apply { + setColor(context.getColor(R.color.accent_surface)) + cornerRadius = context.dpf(8f) + } + minWidth = context.dp(24) + setPadding(context.dp(4), 0, context.dp(4), 0) + visibility = if (isGunsmokeWeek) View.VISIBLE else View.GONE + } + private val frozen = context.row { + setPadding(context.dp(10), 0, context.dp(6), 0) + background = cellBackground(R.color.table_cell, clickable = false) + addView(rank, context.lp(WRAP, WRAP, end = 6)) + addView(context.column { + addView(name, context.lp()) + addView(detail, context.lp()) + }, context.lp(width = 0, weight = 1f)) + } + private val cells = List(DAYS + 1) { MetricCell(if (it == DAYS) TOTAL_WIDTH else DAY_WIDTH, total = it == DAYS) } + val view: View = gridRow(frozen, cells.map { it.view }, rowHeight) + + fun bind( + member: WeeklyReportBuilder.MemberRow, + rankValue: Int?, + edits: Map, + onCell: (WeeklyReportBuilder.DayCell?) -> Unit, + ) { + name.text = member.name + detail.text = "#${member.uid}" + rank.text = rankValue?.toString().orEmpty() + frozen.contentDescription = listOfNotNull( + rankValue?.let { context.getString(R.string.rank_description, it) }, + member.name, + "#${member.uid}", + ).joinToString(". ") + frozen.isFocusable = true + member.days.forEachIndexed { index, cell -> + cells.getOrNull(index)?.bind( + display = daily(cell, edits[cell.gameDay]), + description = context.getString(R.string.weekly_cell_description, member.name, Labels.dayHeader(context, cell.gameDay)), + ) { onCell(cell) } + } + cells.last().bind( + display = total(member), + description = context.getString(R.string.weekly_cell_description, member.name, context.getString(R.string.total)), + ) { onCell(null) } + } + } + + private inner class MetricCell(widthDp: Int, private val total: Boolean) { + private val merit = context.text(null, TextRole.SUBTITLE) { + textSize = 16f + gravity = Gravity.CENTER + maxLines = 1 + } + private val gunsmoke = context.text(null, TextRole.CAPTION) { + gravity = Gravity.CENTER + maxLines = 1 + visibility = if (isGunsmokeWeek) View.VISIBLE else View.GONE + } + private val activity = context.text(null, TextRole.CAPTION) { + gravity = Gravity.CENTER + maxLines = 1 + } + val view: LinearLayout = context.column { + gravity = Gravity.CENTER + setPadding(context.dp(4), context.dp(4), context.dp(4), context.dp(4)) + addView(merit, context.lp()) + addView(gunsmoke, context.lp()) + addView(activity, context.lp()) + layoutParams = LinearLayout.LayoutParams(context.dp(widthDp), rowHeight) + isClickable = true + isFocusable = true + } + + fun bind(display: CellDisplay, description: String, onClick: () -> Unit) { + merit.text = display.merit + merit.setTextColor(context.getColor(if (display.meritWarn) R.color.cutline_mark else R.color.text_primary)) + 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), + ) + } + activity.text = android.text.SpannableStringBuilder().apply { + appendMark(context.getString(R.string.login_initial), display.login) + append(" ") + appendMark(context.getString(R.string.patrol_initial), display.patrol) + } + view.background = cellBackground( + when { + display.edited -> R.color.accent_surface + total -> R.color.table_header + else -> R.color.table_cell + }, + clickable = true, + ) + view.contentDescription = buildString { + append(description) + append(". ") + append(context.getString(R.string.merit_short)).append(' ').append(spoken(display.merit)) + 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 ?: "-")) + append(". ").append(context.getString(R.string.attempt_short)).append(' ').append(spoken(display.attempts ?: "-")) + } + append(". ").append(context.getString(R.string.login_short)).append(' ').append(spoken(display.login)) + append(". ").append(context.getString(R.string.patrol_short)).append(' ').append(spoken(display.patrol)) + if (display.edited) append(". ").append(context.getString(R.string.edited)) + } + view.setOnClickListener { onClick() } + } + + private fun android.text.SpannableStringBuilder.appendMark(label: String, mark: CellDisplay.Mark) { + append(label) + val start = length + append(mark.symbol) + setSpan( + android.text.style.ForegroundColorSpan(context.getColor(mark.color)), + 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(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 + } + + fun daily(cell: WeeklyReportBuilder.DayCell, edit: WeeklyOverrideEditing.CellEdit? = null): CellDisplay { + val dayClosed = !PlatoonPeriods.periodStartInstant(cell.gameDay.plusDays(1), zone).isAfter(now) + val base = CellDisplay( + merit = WeeklyMetricPresentation.format(cell.meritDelta, cell.meritCertainty), + meritWarn = WeeklyMetricPresentation.warnsBelowCutline(cell.meritDelta, cell.meritCertainty, cutlines::belowDailyMerit), + score = if (isGunsmokeWeek) WeeklyMetricPresentation.format(cell.scoreDelta, cell.scoreCertainty) else null, + scoreWarn = isGunsmokeWeek && + WeeklyMetricPresentation.warnsBelowCutline(cell.scoreDelta, cell.scoreCertainty, cutlines::belowDailyScore), + attempts = if (isGunsmokeWeek) { + WeeklyMetricPresentation.format( + cell.attempts, + cell.attemptsCertainty, + missing = if (cell.manualOverride != null) "-" else "?", + ) + } else { + null + }, + attemptsWarn = isGunsmokeWeek && + WeeklyMetricPresentation.warnsBelowCutline(cell.attempts, cell.attemptsCertainty, cutlines::belowDailyAttempts), + login = if (cell.attended == null && cell.evidence != DailyEvidence.MANUAL) { + UNKNOWN_MARK + } else { + activityMark(cell.attended, cell.observed, dayClosed) + }, + patrol = if (cell.dailyPatrol == null && cell.evidence != DailyEvidence.MANUAL) { + UNKNOWN_MARK + } else { + activityMark(cell.dailyPatrol, cell.observed, dayClosed) + }, + edited = false, + ) + if (edit == null || edit.isEmpty) return base + return base.copy( + merit = edit.merit?.let { it.trim().ifEmpty { "-" } } ?: base.merit, + meritWarn = edit.merit == null && base.meritWarn, + score = edit.score?.let { it.trim().ifEmpty { "-" } } ?: base.score, + scoreWarn = edit.score == null && base.scoreWarn, + attempts = edit.attempts?.let { it.trim().ifEmpty { "-" } } ?: base.attempts, + attemptsWarn = edit.attempts == null && base.attemptsWarn, + login = edit.attended?.let { editedMark(it.value) } ?: base.login, + patrol = edit.dailyPatrol?.let { editedMark(it.value) } ?: base.patrol, + edited = true, + ) + } + + fun total(member: WeeklyReportBuilder.MemberRow): CellDisplay = CellDisplay( + merit = WeeklyMetricPresentation.format(member.totalMerit, member.totalMeritCertainty), + meritWarn = WeeklyMetricPresentation.warnsBelowCutline(member.totalMerit, member.totalMeritCertainty, cutlines::belowWeeklyMerit), + score = if (isGunsmokeWeek) WeeklyMetricPresentation.format(member.totalScore, member.totalScoreCertainty) else null, + scoreWarn = isGunsmokeWeek && + WeeklyMetricPresentation.warnsBelowCutline(member.totalScore, member.totalScoreCertainty, cutlines::belowWeeklyScore), + attempts = if (isGunsmokeWeek) WeeklyMetricPresentation.format(member.totalAttempts, member.totalAttemptsCertainty) else null, + attemptsWarn = isGunsmokeWeek && + WeeklyMetricPresentation.warnsBelowCutline(member.totalAttempts, member.totalAttemptsCertainty, cutlines::belowWeeklyAttempts), + login = countMark(member.loginDays, member.loginDaysCertainty, cutlines::belowWeeklyLoginDays), + patrol = countMark(member.patrolDays, member.patrolDaysCertainty, cutlines::belowWeeklyPatrolDays), + 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 + value == false && dayClosed -> NO_MARK + value == false -> NONE_MARK + else -> CellDisplay.Mark("?", R.color.text_secondary, R.string.evidence_unknown_short) + } + + private fun editedMark(value: Boolean?): CellDisplay.Mark = when (value) { + true -> YES_MARK + false -> NO_MARK + null -> NONE_MARK + } + + private fun headerCell(title: String, subtitle: String?, widthDp: Int, gravity: Int): View = context.column { + this.gravity = gravity + setPadding(context.dp(10), 0, context.dp(6), 0) + setBackgroundColor(context.getColor(R.color.table_header)) + addView(context.text(title, TextRole.LABEL) { + setTextColor(context.getColor(R.color.text_primary)) + this.gravity = gravity + }, context.lp()) + subtitle?.let { + addView(context.text(it, TextRole.CAPTION) { this.gravity = gravity }, context.lp()) + } + layoutParams = LinearLayout.LayoutParams(context.dp(widthDp), LinearLayout.LayoutParams.MATCH_PARENT) + } + + /** Grid row: hairline separators come from a 1px gap over the outline-colored row. */ + private fun gridRow(frozen: View, cells: List, height: Int): View { + val hairline = maxOf(1, context.dp(1) / 2) + val scroller = SyncedScrollView(context, sync).apply { + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + cells.forEach { cell -> + val width = (cell.layoutParams as? LinearLayout.LayoutParams)?.width ?: context.dp(DAY_WIDTH) + addView(cell, LinearLayout.LayoutParams(width, height - hairline).apply { marginStart = hairline }) + } + }) + } + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + setBackgroundColor(context.getColor(R.color.outline)) + addView(frozen, LinearLayout.LayoutParams(context.dp(MEMBER_WIDTH), height - hairline)) + addView(scroller, LinearLayout.LayoutParams(0, height, 1f)) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, height) + } + } + + private fun cellBackground(@ColorRes color: Int, clickable: Boolean) = if (clickable) { + RippleDrawable( + ColorStateList.valueOf(context.getColor(R.color.control_ripple)), + ColorDrawable(context.getColor(color)), + null, + ) + } else { + ColorDrawable(context.getColor(color)) + } + + companion object { + const val DAYS = 7 + const val MEMBER_WIDTH = 124 + const val DAY_WIDTH = 76 + const val TOTAL_WIDTH = 124 + const val HEADER_HEIGHT = 48 + const val STANDARD_ROW_HEIGHT = 64 + const val GUNSMOKE_ROW_HEIGHT = 80 + + 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) + private val NONE_MARK = CellDisplay.Mark("-", R.color.text_secondary, R.string.not_observed) + private val UNKNOWN_MARK = CellDisplay.Mark("?", R.color.warning_mark, R.string.evidence_unknown_short) + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt index b1196a4..b0fd535 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt @@ -1,164 +1,102 @@ package dev.gf2log.app -import android.app.Activity import android.content.Intent -import android.graphics.Typeface import android.os.Bundle -import android.view.Gravity -import android.view.ViewGroup -import android.widget.ImageButton import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView import dev.gf2log.app.management.PlatoonRepository -import dev.gf2log.app.management.WeeklyReportHistoryEntry import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.group +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.text import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.concurrent.Executors -/** Lists the bounded immutable history for one weekly table. */ +/** Lists the bounded immutable revisions recorded for one weekly table. */ class WeeklyTableHistoryActivity : BaseActivity() { + override val legacyStyling = false + + private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository - private lateinit var body: LinearLayout - private val worker = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2WeeklyHistory") - } + private lateinit var content: LinearLayout + private val work = BackgroundWork(this, "GF2WeeklyHistory") private val periodStart: LocalDate by lazy { LocalDate.ofEpochDay(intent.getLongExtra(EXTRA_PERIOD_START, Long.MIN_VALUE)) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!intent.hasExtra(EXTRA_PERIOD_START)) { + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope + if (!intent.hasExtra(EXTRA_PERIOD_START) || scope == null) { finish() return } - repository = PlatoonRepository(this) - body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(8), dp(16), dp(16)) - addHeader() - addView(TextView(context).apply { - text = getString(R.string.weekly_table_history_description) - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(2), 0, dp(12)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.weekly_report_loading) - gravity = Gravity.CENTER - setPadding(0, dp(28), 0, dp(28)) - }, matchWidth()) - } - setContentView(ScrollView(this).apply { addView(body, matchWidth()) }) - loadHistory() + repository = PlatoonRepository(this, scope) + val screen = Screen(this) + screen.topBar.setNavigation(::finish).setTitle( + getString(R.string.weekly_table_history), + getString( + R.string.week_range, + Labels.format(Labels.DATE, periodStart), + Labels.format(Labels.DATE, periodStart.plusDays(6)), + ), + ) + content = column() + screen.setBody(Screen.scrollingBody(this, content)) } - override fun onDestroy() { - worker.shutdownNow() - super.onDestroy() - } - - private fun LinearLayout.addHeader() { - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_arrow_back) - contentDescription = getString(R.string.back) - useModernIconStyle() - setPadding(dp(11), dp(11), dp(11), dp(11)) - setOnClickListener { finish() } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(4) }) - addView(TextView(context).apply { - text = getString(R.string.weekly_table_history) - textSize = 22f - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - }, matchWidth()) - } - - private fun loadHistory() { - worker.execute { - val entries = repository.listWeeklyReportHistory(periodStart) - val models = entries.mapNotNull { entry -> - repository.weeklyReportHistory(entry.id, periodStart)?.let { report -> - HistoryRow(entry, report.members.size) - } - } - runOnUiThread { - if (!isFinishing && !isDestroyed) renderRows(models) - } - } - } - - private fun renderRows(rows: List) { - while (body.childCount > HEADER_CHILD_COUNT) body.removeViewAt(HEADER_CHILD_COUNT) - if (rows.isEmpty()) { - body.addView(TextView(this).apply { - text = getString(R.string.weekly_table_history_empty) - gravity = Gravity.CENTER - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(28), 0, dp(28)) - }, matchWidth()) + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) { + finish() return } - val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) - .withLocale(resources.configuration.locales[0]) - .withZone(GameTimeZonePreferences.get(this)) - rows.forEachIndexed { index, row -> - body.addView( - ModernUi.actionRow( - context = this, - title = formatter.format(row.entry.recordedAt), - detail = buildString { - append(getString(R.string.weekly_table_history_member_count, row.memberCount)) - if (row.entry.active) { - append(" · ") - append(getString(R.string.weekly_table_history_active)) - } - }, - onClick = { - startActivityForResult( - Intent(this, WeeklyTableHistoryPreviewActivity::class.java) - .putExtra(EXTRA_PERIOD_START, periodStart.toEpochDay()) - .putExtra(WeeklyTableHistoryPreviewActivity.EXTRA_HISTORY_ID, row.entry.id), - REQUEST_PREVIEW, - ) - }, - ), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { if (index > 0) topMargin = dp(8) }, - ) - } + load() } - @Deprecated("Activity result is retained for API 26 compatibility") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == REQUEST_PREVIEW && resultCode == Activity.RESULT_OK) { - setResult(Activity.RESULT_OK) - finish() + private fun load() { + if (content.childCount == 0) content.addView(StateViews.loading(this, getString(R.string.weekly_report_loading))) + work.run( + key = LOAD, + task = { + repository.listWeeklyReportHistory(periodStart) to + GameTimeZonePreferences.get(this, requireNotNull(profileBinding.scope).storageId) + }, + ) { result -> + content.removeAllViews() + val (entries, zone) = result.getOrElse { + content.addView(StateViews.failure(this, getString(R.string.weekly_report_load_failed)) { load() }) + return@run + } + content.addView(text(getString(R.string.weekly_table_history_description), TextRole.SECONDARY), lp(top = 4, bottom = 12)) + if (entries.isEmpty()) { + content.addView(StateViews.empty(this, R.drawable.ic_history_restore, getString(R.string.weekly_table_history_empty))) + return@run + } + content.addView(group(entries.map { entry -> + navigationRow( + title = Labels.format(Labels.DATE_TIME_SECONDS, entry.recordedAt, zone), + detail = if (entry.active) getString(R.string.weekly_table_history_active) else null, + icon = if (entry.active) R.drawable.ic_check_circle else R.drawable.ic_history_restore, + ) { + startActivity( + Intent(this, WeeklyTableHistoryPreviewActivity::class.java) + .putExtra(EXTRA_PERIOD_START, periodStart.toEpochDay()) + .putExtra(WeeklyTableHistoryPreviewActivity.EXTRA_HISTORY_ID, entry.id) + .putExtra(WeeklyTableHistoryPreviewActivity.EXTRA_RECORDED_AT, entry.recordedAt.toEpochMilli()), + ) + } + }), lp()) } } - private data class HistoryRow( - val entry: WeeklyReportHistoryEntry, - val memberCount: Int, - ) - - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - companion object { const val EXTRA_PERIOD_START = "weekly_history_period_start" - private const val REQUEST_PREVIEW = 80 - private const val HEADER_CHILD_COUNT = 2 + private const val LOAD = "load" } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt index 7591506..fae83f0 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt @@ -1,176 +1,165 @@ package dev.gf2log.app -import android.app.Activity -import android.app.AlertDialog -import android.graphics.Bitmap -import android.graphics.Typeface +import android.content.Intent import android.os.Bundle -import android.view.Gravity import android.view.ViewGroup import android.widget.Button -import android.widget.HorizontalScrollView -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import dev.gf2log.app.management.PlatoonRepository -import dev.gf2log.app.management.WeeklyShareProjection +import dev.gf2log.app.management.WeeklyEvidenceAnalyzer +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.settings.WeeklyCutlinePreferences +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.SingleViewAdapter +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.text +import java.time.Instant import java.time.LocalDate -import java.util.concurrent.Executors -/** Shows one immutable history projection and provides the explicit restore action. */ +/** Read-only view of one recorded weekly table with the explicit restore action. */ class WeeklyTableHistoryPreviewActivity : BaseActivity() { + override val legacyStyling = false + + private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository - private lateinit var body: LinearLayout - private val worker = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2WeeklyHistoryPreview") - } - private var bitmap: Bitmap? = null + private val work = BackgroundWork(this, "GF2WeeklyHistoryPreview") + private val topAdapter = SingleViewAdapter() + private val headerAdapter = SingleViewAdapter() + private val rowsAdapter = RowsAdapter() private val periodStart: LocalDate by lazy { LocalDate.ofEpochDay(intent.getLongExtra(WeeklyTableHistoryActivity.EXTRA_PERIOD_START, 0L)) } private val historyId: Long by lazy { intent.getLongExtra(EXTRA_HISTORY_ID, -1L) } + private var restoreButton: Button? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (historyId < 0L || !intent.hasExtra(WeeklyTableHistoryActivity.EXTRA_PERIOD_START)) { + profileBinding = ActivePlatoonScopeBinding(this) + val scope = profileBinding.scope + if (historyId < 0L || !intent.hasExtra(WeeklyTableHistoryActivity.EXTRA_PERIOD_START) || scope == null) { finish() return } - repository = PlatoonRepository(this) - body = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(8), dp(16), dp(16)) - addView(buildHeader(), matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.weekly_report_loading) - gravity = Gravity.CENTER - setPadding(0, dp(32), 0, dp(32)) - }, matchWidth()) + repository = PlatoonRepository(this, scope) + val zone = GameTimeZonePreferences.get(this, scope.storageId) + val recordedAt = intent.getLongExtra(EXTRA_RECORDED_AT, -1L).takeIf { it >= 0 }?.let(Instant::ofEpochMilli) + val screen = Screen(this) + screen.topBar.setNavigation(::finish).setTitle( + getString(R.string.weekly_table_history_preview), + recordedAt?.let { Labels.format(Labels.DATE_TIME_SECONDS, it, zone) }, + ) + screen.setBody(RecyclerView(this).apply { + id = R.id.screen_list + layoutManager = LinearLayoutManager(context) + adapter = ConcatAdapter(topAdapter, headerAdapter, rowsAdapter) + itemAnimator = null + }) + topAdapter.view = StateViews.loading(this, getString(R.string.weekly_report_loading)) + work.run( + task = { + repository.weeklyReportHistory(historyId, periodStart) to WeeklyCutlinePreferences(this, scope.storageId).read() + }, + ) { result -> + val (report, cutlines) = result.getOrNull() ?: (null to null) + if (report == null || cutlines == null) { + topAdapter.view = StateViews.empty(this, R.drawable.ic_history_restore, getString(R.string.weekly_table_history_unavailable)) + return@run + } + val table = WeeklyTable(this, report.isGunsmokeWeek, zone, cutlines) + topAdapter.view = column { + setPadding(dp(16), dp(4), dp(16), dp(12)) + addView(panel(emphasized = true, padding = 12) { + addView(text(getString(R.string.weekly_table_history_preview_notice), TextRole.SECONDARY) { + setTextColor(getColor(R.color.accent_text)) + }, lp()) + restoreButton = button(getString(R.string.weekly_table_history_restore), ModernUi.ControlRole.PRIMARY) { + confirmRestore() + } + addView(restoreButton, lp(top = 8)) + }, lp()) + } + if (report.members.isNotEmpty()) headerAdapter.view = table.headerRow(report) + rowsAdapter.show(table, report) } - setContentView(ScrollView(this).apply { addView(body, matchWidth()) }) - loadPreview() - } - - override fun onDestroy() { - worker.shutdownNow() - bitmap?.recycle() - bitmap = null - super.onDestroy() } - private fun buildHeader() = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_arrow_back) - contentDescription = getString(R.string.back) - useModernIconStyle() - setPadding(dp(11), dp(11), dp(11), dp(11)) - setOnClickListener { finish() } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(4) }) - addView(TextView(context).apply { - text = getString(R.string.weekly_table_history_preview) - textSize = 22f - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + override fun onScreenResumed() { + if (!profileBinding.isCurrent(this)) finish() } - private fun loadPreview() { - worker.execute { - val report = repository.weeklyReportHistory(historyId, periodStart) - val rendered = report?.let { - WeeklyReportPngRenderer.render( - WeeklyShareProjection.build( - report = it, - displayedMembers = it.members, - privateNotesByUid = emptyMap(), - privacy = WeeklyShareProjection.Privacy(), - ), - ) - } - runOnUiThread { - if (isFinishing || isDestroyed) { - rendered?.recycle() - } else if (rendered == null) { - showUnavailable() + private fun confirmRestore() { + Dialogs.confirm( + this, + R.string.weekly_table_history_restore, + R.string.weekly_table_history_restore_warning, + R.string.weekly_table_history_restore, + ) { + restoreButton?.isEnabled = false + work.run(task = { repository.restoreWeeklyReportHistory(historyId, periodStart) }) { result -> + if (result.getOrDefault(false)) { + Feedback.show(this, R.string.weekly_table_history_restored) + startActivity( + Intent(this, WeeklyReportActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + finish() } else { - bitmap = rendered - renderPreview(rendered) + restoreButton?.isEnabled = true + Feedback.show(this, R.string.weekly_table_history_restore_failed, long = true) } } } } - private fun renderPreview(image: Bitmap) { - while (body.childCount > 1) body.removeViewAt(1) - body.addView(HorizontalScrollView(this).apply { - isFillViewport = true - addView(ImageView(context).apply { - setImageBitmap(image) - adjustViewBounds = true - contentDescription = getString(R.string.weekly_table_history_preview) - }, ViewGroup.LayoutParams(image.width, image.height)) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) - body.addView(Button(this).apply { - text = getString(R.string.weekly_table_history_restore) - usePrimaryActionStyle() - setOnClickListener { confirmRestore() } - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - dp(48), - ).apply { topMargin = dp(16) }) - } + private inner class RowsAdapter : RecyclerView.Adapter() { + private var table: WeeklyTable? = null + private var report: WeeklyReportBuilder.Report? = null - private fun showUnavailable() { - while (body.childCount > 1) body.removeViewAt(1) - body.addView(TextView(this).apply { - text = getString(R.string.weekly_table_history_restore_failed) - gravity = Gravity.CENTER - setPadding(0, dp(32), 0, dp(32)) - }, matchWidth()) - } + fun show(nextTable: WeeklyTable, nextReport: WeeklyReportBuilder.Report) { + table = nextTable + report = nextReport + notifyDataSetChanged() + } - private fun confirmRestore() { - AlertDialog.Builder(this) - .setTitle(R.string.weekly_table_history_restore) - .setMessage(R.string.weekly_table_history_restore_warning) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.weekly_table_history_restore) { _, _ -> restore() } - .show() - } + override fun getItemCount() = report?.members?.size ?: 0 - private fun restore() { - worker.execute { - val restored = repository.restoreWeeklyReportHistory(historyId, periodStart) - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - if (restored) { - TransientMessage.show(this, R.string.weekly_table_history_restored) - setResult(Activity.RESULT_OK) - finish() - } else { - TransientMessage.show( - this, - R.string.weekly_table_history_restore_failed, - Toast.LENGTH_LONG, - ) - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = RowHolder(requireNotNull(table).MemberRow()) + + override fun onBindViewHolder(holder: RowHolder, position: Int) { + val current = report ?: return + val member = current.members[position] + holder.row.bind(member, (position + 1).takeIf { current.isGunsmokeWeek }, emptyMap()) { cell -> + val label = cell?.let { Labels.dayHeader(this@WeeklyTableHistoryPreviewActivity, it.gameDay) } ?: getString(R.string.total) + WeeklyDialogs.showEvidence( + this@WeeklyTableHistoryPreviewActivity, + getString(R.string.weekly_cell_title, member.name, label), + current.isGunsmokeWeek, + cell?.let { requireNotNull(table).daily(it) } ?: requireNotNull(table).total(member), + WeeklyEvidenceAnalyzer.Metric.entries.associateWith { metric -> + if (cell == null) WeeklyEvidenceAnalyzer.explainTotal(member, metric) else WeeklyEvidenceAnalyzer.explainDaily(cell, metric) + }, + ) } } } - private fun matchWidth() = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + private class RowHolder(val row: WeeklyTable.MemberRow) : RecyclerView.ViewHolder(row.view) companion object { const val EXTRA_HISTORY_ID = "weekly_history_id" + const val EXTRA_RECORDED_AT = "weekly_history_recorded_at" } } 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 d276faf..e861645 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Labels.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Labels.kt @@ -43,11 +43,23 @@ internal object Labels { }, ) + /** Server name without the client prefix, for places where the client is already shown. */ + fun regionShort(context: Context, region: GameServerRegion): String = context.getString( + when (region) { + GameServerRegion.MANUAL -> R.string.server_region_manual + GameServerRegion.DARKWINTER_GLOBAL, GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_short_global + GameServerRegion.DARKWINTER_CHINA -> R.string.server_short_china + GameServerRegion.HAOPLAY_JAPAN -> R.string.server_short_japan + GameServerRegion.HAOPLAY_KOREA -> R.string.server_short_korea + GameServerRegion.HAOPLAY_ASIA -> R.string.server_short_asia + }, + ) + /** "HaoPlay · South Korea · ID 1234" */ fun profileIdentity(context: Context, profile: PlatoonProfile): String = context.getString( R.string.profile_identity, profile.client.displayName, - region(context, profile.serverRegion), + regionShort(context, profile.serverRegion), profile.platoonId, ) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index a1eae37..23420fc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -164,7 +164,7 @@ 주간 과업표를 불러오는 중… 주간 과업표를 불러올 수 없습니다. 다시 시도 - 이 주간에는 아직 비교 가능한 데이터가 없습니다. + 서로 다른 게임 일자에 서클 멤버 화면을 캡처하거나 명단 CSV를 가져오면 이 주의 표가 만들어집니다. 멤버 순위 합계 @@ -527,4 +527,67 @@ %1$d명 선택 + 적용 + 최소 %1$s + 0–3 · 비우면 해제 + 커트라인 미달 + 비우면 해제 + 수정 취소 + 자동 + 수동 값 지우기 + 0 이상의 정수를 입력하세요. + 0–%1$d + 0부터 %1$d까지의 정수를 입력하세요. + 비고 삭제: %1$s + 저장하지 않은 표 수정 내용이 사라집니다. + 수정 내용을 버릴까요? + 표 수정 중 + 수정됨 + 모든 값 확정 + 출석과 일일 과업: ✓ 함, × 안 함 확정, - 관측 없음, ? 판단 불가. + 관측 %1$d/%2$d일 · 확정 %3$d · 최솟값 %4$d · 미확인 %5$d + 일부 값은 최솟값이거나 미확인 + 알 수 없음 + 이번 주로 + 안내 + 참여 횟수는 0부터 3까지입니다. + 0 이상의 정수를 입력하거나 비워 두세요. + %1$s의 %2$s 값을 확인하세요. + 출 + 게임에서 확인한 값만 바꾸세요. 손대지 않은 항목은 캡처 근거를 유지합니다. + 아니요 + 이번 주에는 가입·탈퇴가 없습니다. + 이번 주 비고가 없습니다. + 이 주의 표가 아직 없습니다 + 관측 없음 + 비고를 추가했습니다 + 게임 일자 + 비고를 입력하세요. + 과 + 근거: 관측 %1$d/%2$d일 · 확정 %3$d · 최솟값 %4$d · 미확인 %5$d + %1$d위 + 주 선택 + 민감한 정보입니다. 기본값은 꺼짐입니다. + 현재 표 보기 + 수정 시작 + 합계는 일별 칸에서 계산됩니다. + 나가기 전에 변경 내용을 저장할까요? + 저장하지 않은 변경 + %1$s – %2$s + 초기화 05:00 · %1$s + %1$s, %2$s + %1$s · %2$s + 날짜 칸을 눌러 값을 바꾸세요. 수정한 칸은 저장할 때까지 강조됩니다. + 기록된 이전 버전의 표입니다. 복원하면 새 근거로 표가 바뀔 때까지 이 버전을 표시합니다. + 이 기록은 더 이상 사용할 수 없습니다. + 표 히스토리 (%1$d) + 예 + + %1$d칸 수정 + + 글로벌 + 중국 + 일본 + 한국 + 아시아 diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 8180c91..84863a7 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -12,4 +12,12 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 989e1c5..be69961 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,7 +164,7 @@ Loading weekly table… Unable to load the weekly table. Retry - No complete daily snapshot pairs are available for this week. + Capture the Platoon Members page on two different game days, or import roster CSV files, to build this week\'s table. Member Rank Total @@ -533,4 +533,68 @@ Join: %3$s · Withdraw: %4$s %1$d selected %1$d selected + Apply + at least %1$s + 0–3 · blank clears + below cutline + Leave blank to clear + Cancel editing + Auto + Clear manual values + Enter a whole number of 0 or more. + 0–%1$d + Enter a whole number from 0 to %1$d. + Delete note: %1$s + Your unsaved changes to this table will be lost. + Discard changes? + Editing table + Edited + All values confirmed + Login and Daily Patrol: ✓ yes, × confirmed no, - not observed, ? undetermined. + Observed %1$d/%2$d days · exact %3$d · minimum %4$d · unknown %5$d + Some values are minimums or unknown + unknown + This week + Guide + Attempts must be between 0 and 3. + Enter a whole number of 0 or more, or leave it blank. + Check the value for %1$s on %2$s. + L + Only change values you can confirm in the game. Untouched fields keep their captured evidence. + No + No joins or withdrawals this week. + No notes for this week. + No table for this week yet + not observed + Note added + Game day + Enter a note. + P + Evidence: %1$d/%2$d days observed · exact %3$d · minimum %4$d · unknown %5$d + Rank %1$d + Choose a week + Sensitive. Off by default. + Show live table + Start editing + Totals are calculated from the daily cells. + Save your changes before leaving? + Unsaved changes + %1$s – %2$s + Reset 05:00 · %1$s + %1$s, %2$s + %1$s · %2$s + Tap a day to change its values. Edited cells are highlighted until you save. + This is a recorded version of the table. Restoring shows it until new evidence changes the table. + This recorded table is no longer available. + Table history (%1$d) + Yes + + %1$d cell changed + %1$d cells changed + + Global + China + Japan + South Korea + Asia diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt index 02247eb..2d5431a 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt @@ -1,6 +1,6 @@ package dev.gf2log.app.management -import dev.gf2log.app.WeeklyPngPendingState +import dev.gf2log.app.WeeklyPngExport import java.time.LocalDate import org.junit.Assert.assertEquals @@ -18,23 +18,23 @@ class WeeklyShareProjectionTest { @Test fun pendingWeeklyPngRestoresOnlyFromTheBoundedShareCache() { val cache = temporary.newFolder("cache") - WeeklyPngPendingState.directory(cache).mkdirs() + WeeklyPngExport.directory(cache).mkdirs() val periodStart = LocalDate.of(2026, 8, 9) - val published = WeeklyPngPendingState.newRenderTarget(cache, periodStart).apply { + val published = WeeklyPngExport.newRenderTarget(cache, periodStart).apply { writeBytes(byteArrayOf(1, 2, 3)) } - val nextRender = WeeklyPngPendingState.newRenderTarget(cache, periodStart) + val nextRender = WeeklyPngExport.newRenderTarget(cache, periodStart) - val savedName = WeeklyPngPendingState.nameForState(cache, published) + val savedName = WeeklyPngExport.nameForState(cache, published) assertNotEquals(published.name, nextRender.name) - assertEquals("GF2logger-week-20260809.png", WeeklyPngPendingState.exportName(periodStart)) - assertEquals(published.canonicalFile, WeeklyPngPendingState.restore(cache, savedName)) - assertEquals(null, WeeklyPngPendingState.restore(cache, "../outside.png")) - assertEquals(null, WeeklyPngPendingState.restore(cache, "GF2logger-week-20260809.png")) - assertEquals(null, WeeklyPngPendingState.restore(cache, nextRender.name)) + assertEquals("GF2logger-week-20260809.png", WeeklyPngExport.exportName(periodStart)) + assertEquals(published.canonicalFile, WeeklyPngExport.restore(cache, savedName)) + assertEquals(null, WeeklyPngExport.restore(cache, "../outside.png")) + assertEquals(null, WeeklyPngExport.restore(cache, "GF2logger-week-20260809.png")) + assertEquals(null, WeeklyPngExport.restore(cache, nextRender.name)) val outside = temporary.newFile(published.name) - assertEquals(null, WeeklyPngPendingState.nameForState(cache, outside)) + assertEquals(null, WeeklyPngExport.nameForState(cache, outside)) } @Test From db6031d11c2fa5f8ebc5b0ba6ff2baddd31f0df0 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:22:36 +0900 Subject: [PATCH 019/125] fix: derive member activity from one canonical period rule --- .../PlatoonDatabaseIntegrationTest.kt | 43 +++++++++++ .../management/ExactBoundaryCorrelation.kt | 14 +--- .../management/MembershipConsistencyPolicy.kt | 2 - .../app/management/MembershipEditing.kt | 15 +--- .../gf2log/app/management/MembershipRows.kt | 75 ++++++++++++++----- .../management/PlatoonDatabaseMigrations.kt | 2 +- .../app/management/RosterHistoryReplay.kt | 2 +- .../app/management/SnapshotIngestion.kt | 15 ++-- .../MembershipConsistencyPolicyTest.kt | 9 +-- 9 files changed, 116 insertions(+), 61 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 4942a13..2b2363c 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -568,6 +568,49 @@ class PlatoonDatabaseIntegrationTest { } } + @Test + fun everyWriterAgreesWithTheCanonicalActiveStateRule() { + val rosterAt = Instant.parse("2026-07-31T00:00:00Z") + database.ingestSnapshot( + PlatoonSnapshot( + id = 0, + capturedAt = rosterAt, + sourceFile = "current.csv", + members = listOf(member(OTHER_UID, "Current")), + ), + EvidenceSource.SNAPSHOT, + ) + database.ingestPlatoonUpdates( + listOf( + update( + PlatoonUpdateSemantics.KIND_JOIN, + rosterAt.minusSeconds(3_600), + TARGET_UID, + "Backfilled", + ), + ), + rosterAt.plusSeconds(60), + ) + assertFalse(database.listMemberStatuses().single { it.uid == TARGET_UID }.isActive) + + database.ingestSnapshot( + PlatoonSnapshot( + id = 0, + capturedAt = rosterAt.minusSeconds(7_200), + sourceFile = "older.csv", + members = listOf(member(OTHER_UID, "Current")), + ), + EvidenceSource.LEGACY_IMPORT, + historicalOnly = true, + ) + val written = database.listMemberStatuses().associate { it.uid to it.isActive } + assertEquals(mapOf(OTHER_UID to true, TARGET_UID to false), written) + + MembershipRows.synchronizeAllActiveStates(database.writableDatabase) + + assertEquals(written, database.listMemberStatuses().associate { it.uid to it.isActive }) + } + @Test fun schemaTwelveUpgradeRepairsDerivedActiveState() { val databaseName = "platoon-v12-membership-state-test.db" 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 7ca7dba..a02a2db 100644 --- a/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt +++ b/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt @@ -78,6 +78,7 @@ internal object ExactBoundaryCorrelation { db: SQLiteDatabase, uid: Long, ) { + MembershipRows.synchronizeActiveState(db, listOf(uid)) val latestSnapshotAt = SnapshotIngestion.latestSnapshotCapturedAt(db) val snapshotFilter = if (latestSnapshotAt == null) "" else "AND occurred_at > ?" val arguments = mutableListOf( @@ -124,18 +125,6 @@ internal object ExactBoundaryCorrelation { "members", ContentValues().apply { put("current_name", latestBoundary.name) - put( - "is_active", - if (latestBoundary.type in setOf( - MemberEventType.JOINED, - MemberEventType.REJOINED, - ) - ) { - 1 - } else { - 0 - }, - ) put("last_seen_at", latestBoundary.occurredAt.toEpochMilli()) }, "uid = ?", @@ -160,7 +149,6 @@ internal object ExactBoundaryCorrelation { uid = member.uid, name = member.name, level = 0, - isActive = false, firstSeenAt = observedAt, lastSeenAt = observedAt, ) diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt index 9e9314e..74ac57d 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt @@ -39,8 +39,6 @@ internal object MembershipConsistencyPolicy { return null } - fun isActive(periods: List): Boolean = periods.any { it.leftAt == null } - private fun hasValidOwnChronology(period: MembershipInterval): Boolean { val joinedAt = period.joinedAt ?: return true val leftAt = period.leftAt ?: return true 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 2612903..e4b2cc3 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -30,7 +30,6 @@ internal object MembershipEditing { uid = uid, name = name.trim(), level = 0, - isActive = false, firstSeenAt = joined.instant, lastSeenAt = withdrew.instant, customName = name.trim(), @@ -213,7 +212,7 @@ internal object MembershipEditing { } /** - * Derives current activity from the canonical period timeline, then verifies chronology and + * Verifies chronology, derives current activity from the canonical periods, and verifies * event ownership before the surrounding transaction may commit. */ private fun synchronizeAndVerifyMembershipState(db: SQLiteDatabase, uid: Long) { @@ -221,15 +220,9 @@ internal object MembershipEditing { checkNotNull(periods.takeIf { it.isNotEmpty() }) { "A member must retain a membership period" } val violation = MembershipConsistencyPolicy.violation(periods) check(violation == null) { violation.orEmpty() } - val updated = db.update( - "members", - ContentValues().apply { - put("is_active", if (MembershipConsistencyPolicy.isActive(periods)) 1 else 0) - }, - "uid = ?", - arrayOf(uid.toString()), - ) - check(updated == 1) { "Membership state has no owning member" } + check(MembershipRows.synchronizeActiveState(db, listOf(uid)) == 1) { + "Membership state has no owning member" + } val mismatchedEvent = db.rawQuery( "SELECT 1 FROM member_events event " + "JOIN membership_periods period ON period.id = event.membership_period_id " + 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 c25ef82..20a93a6 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipRows.kt @@ -10,12 +10,12 @@ internal object MembershipRows { fun memberExists(db: SQLiteDatabase, uid: Long): Boolean = db.exists("SELECT 1 FROM members WHERE uid = ? LIMIT 1", uid) + /** Inserts an inactive member; callers derive activity with [synchronizeActiveState]. */ fun insertMember( db: SQLiteDatabase, uid: Long, name: String, level: Long, - isActive: Boolean, firstSeenAt: Instant, lastSeenAt: Instant, customName: String? = null, @@ -28,7 +28,7 @@ internal object MembershipRows { put("current_name", name) if (customName != null) put("custom_name", customName) put("current_level", level) - put("is_active", if (isActive) 1 else 0) + put("is_active", 0) put("first_seen_at", firstSeenAt.toEpochMilli()) put("last_seen_at", lastSeenAt.toEpochMilli()) put("note", "") @@ -85,9 +85,7 @@ internal object MembershipRows { uid: Long, source: EvidenceSource, ) { - if (db.exists("SELECT 1 FROM membership_periods WHERE uid = ? AND left_at IS NULL LIMIT 1", uid)) { - return - } + if (db.exists("${effectiveOpenPeriod("?")} LIMIT 1", uid)) return insertMembershipPeriod( db = db, uid = uid, @@ -221,19 +219,60 @@ internal object MembershipRows { } /** - * Repairs the derived active flag while upgrading databases created before schema v13. - * Leaves every evidence period intact and derives only the current-state projection. + * 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. */ - fun synchronizeAllMembershipStates(db: SQLiteDatabase) { - db.execSQL( - """ - UPDATE members - SET is_active = CASE WHEN EXISTS ( - SELECT 1 - FROM membership_periods period - WHERE period.uid = members.uid AND period.left_at IS NULL - ) THEN 1 ELSE 0 END - """.trimIndent(), - ) + private fun effectiveOpenPeriod(uid: String): String = + """ + SELECT 1 FROM membership_periods current_period + WHERE current_period.uid = $uid + AND current_period.left_at IS NULL + AND NOT ( + current_period.joined_source = '${EvidenceSource.GAME_UPDATES.name}' + AND ( + EXISTS ( + SELECT 1 FROM membership_periods later_exit + WHERE later_exit.uid = current_period.uid + AND later_exit.id != current_period.id + AND later_exit.left_at > current_period.joined_at + ) + OR EXISTS ( + SELECT 1 FROM snapshots latest_roster + WHERE latest_roster.captured_at = (SELECT MAX(captured_at) FROM snapshots) + AND latest_roster.captured_at > current_period.joined_at + AND NOT EXISTS ( + SELECT 1 + FROM snapshot_members roster_member + JOIN snapshots roster ON roster.id = roster_member.snapshot_id + WHERE roster_member.uid = current_period.uid + AND roster.captured_at = latest_roster.captured_at + ) + ) + ) + ) + """.trimIndent() + + private val ACTIVE_STATE_UPDATE = + "UPDATE members SET is_active = CASE WHEN EXISTS (${effectiveOpenPeriod("members.uid")}) " + + "THEN 1 ELSE 0 END" + + /** Re-derives the active flag of [uids] inside the caller's transaction; returns rows updated. */ + fun synchronizeActiveState(db: SQLiteDatabase, uids: Collection): Int = + uids.distinct().chunked(MAX_BOUND_ARGUMENTS).sumOf { chunk -> + db.compileStatement( + "$ACTIVE_STATE_UPDATE WHERE uid IN (${chunk.joinToString { "?" }})", + ).use { statement -> + chunk.forEachIndexed { index, uid -> statement.bindLong(index + 1, uid) } + statement.executeUpdateDelete() + } + } + + fun synchronizeAllActiveStates(db: SQLiteDatabase) { + db.execSQL(ACTIVE_STATE_UPDATE) } + + private const val MAX_BOUND_ARGUMENTS = 500 } 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 7ff54c8..f70a7ec 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseMigrations.kt @@ -31,7 +31,7 @@ internal object PlatoonDatabaseMigrations { PlatoonDatabaseSchema.createPlatoonActivityRetentionIndex(db) } if (oldVersion < 12) PlatoonDatabaseSchema.createWeeklyReportHistoryTables(db) - if (oldVersion < 13) MembershipRows.synchronizeAllMembershipStates(db) + if (oldVersion < 13) MembershipRows.synchronizeAllActiveStates(db) } private fun renameTenuresToMembershipPeriods(db: SQLiteDatabase, oldVersion: Int) { diff --git a/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt b/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt index 84a8b24..1bf7197 100644 --- a/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt +++ b/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt @@ -29,6 +29,7 @@ internal object RosterHistoryReplay { } } } + MembershipRows.synchronizeAllActiveStates(db) ActivityIngestion.resolveUnresolvedActivityUids(db) } @@ -68,7 +69,6 @@ internal object RosterHistoryReplay { uid = uid, name = latest.second.name, level = latest.second.level.toLong(), - isActive = uid in latestFrame.members, firstSeenAt = firstSeenAt, lastSeenAt = latest.third, ) 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 2cf4b48..c6fb1f4 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt @@ -72,6 +72,10 @@ internal object SnapshotIngestion { snapshot.members.forEach { member -> ensureRosterActiveMembershipPeriod(db, member.uid, source) } + MembershipRows.synchronizeActiveState( + db, + snapshot.members.map(SnapshotMember::uid) + changes.left.map { it.uid }, + ) ActivityIngestion.trimPlatoonActivity(db) ActivityIngestion.resolveUnresolvedActivityUids(db) return SnapshotIngestResult( @@ -225,7 +229,7 @@ internal object SnapshotIngestion { precision = EvidencePrecision.INFERRED, source = source, ) - markInactive(db, member.uid, observedAt) + recordLastSeen(db, member.uid, observedAt) if (exactMembershipPeriodId != null) return insertSnapshotBoundaryEvent( db, @@ -289,7 +293,6 @@ internal object SnapshotIngestion { val existing = ContentValues().apply { put("current_name", member.name) put("current_level", member.level) - put("is_active", 1) put("last_seen_at", observedAt.toEpochMilli()) } if (db.update("members", existing, "uid = ?", arrayOf(member.uid.toString())) == 0) { @@ -298,20 +301,16 @@ internal object SnapshotIngestion { uid = member.uid, name = member.name, level = member.level, - isActive = true, firstSeenAt = observedAt, lastSeenAt = observedAt, ) } } - private fun markInactive(db: SQLiteDatabase, uid: Long, observedAt: Instant) { + private fun recordLastSeen(db: SQLiteDatabase, uid: Long, observedAt: Instant) { db.update( "members", - ContentValues().apply { - put("is_active", 0) - put("last_seen_at", observedAt.toEpochMilli()) - }, + ContentValues().apply { put("last_seen_at", observedAt.toEpochMilli()) }, "uid = ?", arrayOf(uid.toString()), ) diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt index 079c385..cd83ce6 100644 --- a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt @@ -2,22 +2,19 @@ package dev.gf2log.app.management import java.time.Instant import java.time.LocalDate -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test class MembershipConsistencyPolicyTest { @Test - fun adjacentPeriodsAreValidAndTheOpenTailDefinesCurrentActivity() { + fun adjacentPeriodsAreValid() { val periods = listOf( interval(1, "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), interval(2, "2026-02-01T00:00:00Z", null), ) assertNull(MembershipConsistencyPolicy.violation(periods)) - assertTrue(MembershipConsistencyPolicy.isActive(periods)) } @Test @@ -41,11 +38,10 @@ class MembershipConsistencyPolicyTest { } @Test - fun closedTimelineIsInactive() { + fun closedTimelineIsValid() { val periods = listOf(interval(1, "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z")) assertNull(MembershipConsistencyPolicy.violation(periods)) - assertFalse(MembershipConsistencyPolicy.isActive(periods)) } @Test @@ -56,7 +52,6 @@ class MembershipConsistencyPolicyTest { ) assertNull(MembershipConsistencyPolicy.violation(periods)) - assertTrue(MembershipConsistencyPolicy.isActive(periods)) } @Test From da17b982a24f5eb9519264962398499a20f86b45 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:24:56 +0900 Subject: [PATCH 020/125] fix: load member periods in one query --- .../app/management/PlatoonReadQueries.kt | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 deletions(-) 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 b7a55b8..0659034 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt @@ -64,8 +64,10 @@ internal object PlatoonReadQueries { } } + /** Two queries in total: the members, then every listed member's periods at once. */ fun listMemberStatuses(db: SQLiteDatabase, activeOnly: Boolean): List { val selection = if (activeOnly) "is_active = 1" else null + val periodsByUid = readMembershipPeriods(db, selection) return db.query( "members", MEMBER_COLUMNS, @@ -87,7 +89,7 @@ internal object PlatoonReadQueries { firstSeenAt = Instant.ofEpochMilli(cursor.getLong(4)), lastSeenAt = Instant.ofEpochMilli(cursor.getLong(5)), note = cursor.getString(6), - membershipPeriods = readMembershipPeriods(db, uid), + membershipPeriods = periodsByUid[uid].orEmpty(), ), ) } @@ -95,40 +97,39 @@ internal object PlatoonReadQueries { } } - private fun readMembershipPeriods(db: SQLiteDatabase, uid: Long): List = - db.query( - "membership_periods", - MEMBERSHIP_PERIOD_COLUMNS, - "uid = ?", - arrayOf(uid.toString()), - null, - null, - "CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, joined_at, id", - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - MembershipPeriod( - id = cursor.getLong(0), - uid = uid, - joinedAt = cursor.getNullableInstant(1), - leftAt = cursor.getNullableInstant(2), - joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), - leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), - joinedTimeKnown = cursor.getInt(5) != 0, - leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 }, - joinedPrecision = enumValueOf(cursor.getString(7)), - leftPrecision = cursor.getNullableString(8) - ?.let(EvidencePrecision::valueOf), - joinedSource = enumValueOf(cursor.getString(9)), - leftSource = cursor.getNullableString(10) - ?.let(EvidenceSource::valueOf), - note = cursor.getString(11), - ), - ) - } - } + private fun readMembershipPeriods( + db: SQLiteDatabase, + memberSelection: String?, + ): Map> = db.query( + "membership_periods", + MEMBERSHIP_PERIOD_COLUMNS, + memberSelection?.let { "uid IN (SELECT uid FROM members WHERE $it)" }, + null, + null, + null, + "uid, CASE WHEN joined_at IS NULL THEN 1 ELSE 0 END, joined_at, id", + ).use { cursor -> + val periods = mutableMapOf>() + while (cursor.moveToNext()) { + val uid = cursor.getLong(12) + periods.getOrPut(uid, ::mutableListOf) += MembershipPeriod( + id = cursor.getLong(0), + uid = uid, + joinedAt = cursor.getNullableInstant(1), + leftAt = cursor.getNullableInstant(2), + joinedDate = cursor.getNullableLong(3)?.let(LocalDate::ofEpochDay), + leftDate = cursor.getNullableLong(4)?.let(LocalDate::ofEpochDay), + joinedTimeKnown = cursor.getInt(5) != 0, + leftTimeKnown = cursor.getNullableInt(6)?.let { it != 0 }, + joinedPrecision = enumValueOf(cursor.getString(7)), + leftPrecision = cursor.getNullableString(8)?.let(EvidencePrecision::valueOf), + joinedSource = enumValueOf(cursor.getString(9)), + leftSource = cursor.getNullableString(10)?.let(EvidenceSource::valueOf), + note = cursor.getString(11), + ) } + periods + } fun listEvents( db: SQLiteDatabase, @@ -264,5 +265,6 @@ internal object PlatoonReadQueries { "joined_source", "left_source", "note", + "uid", ) } From 0d83e2743d8263ee7766fad73fd60bd44e56b162 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:24:57 +0900 Subject: [PATCH 021/125] fix: batch inferred boundary correlation per activity ingest --- .../app/management/ActivityIngestion.kt | 95 ++++++++----------- 1 file changed, 39 insertions(+), 56 deletions(-) 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 51aff66..d3dfed0 100644 --- a/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt +++ b/app/src/main/java/dev/gf2log/app/management/ActivityIngestion.kt @@ -283,7 +283,14 @@ internal object ActivityIngestion { } } + /** + * Upgrades an inferred roster boundary to an exact one when a single linked activity fact + * falls within the correlation window. Candidate boundaries are loaded once per ingest; an + * applied boundary becomes exact and therefore leaves the candidate set. + */ private fun reconcileInferredMembershipBoundaries(db: SQLiteDatabase) { + val boundariesByUid = readInferredBoundaries(db) + if (boundariesByUid.isEmpty()) return val activities = db.rawQuery( """ SELECT id, occurred_at, member_name, resolved_uid @@ -291,11 +298,17 @@ internal object ActivityIngestion { WHERE member_event_id IS NULL AND resolved_uid IS NOT NULL AND action_id NOT IN (?, ?) + AND resolved_uid IN ( + SELECT uid FROM membership_periods + WHERE joined_precision = ? OR left_precision = ? + ) ORDER BY occurred_at, id """.trimIndent(), arrayOf( DAILY_PATROL_REWARD_ACTION_ID.toString(), DAILY_PATROL_RELATED_ACTION_ID.toString(), + EvidencePrecision.INFERRED.name, + EvidencePrecision.INFERRED.name, ), ).use { cursor -> buildList { @@ -312,8 +325,12 @@ internal object ActivityIngestion { } } activities.forEach { activity -> - val boundaries = readNearbyInferredBoundaries(db, activity) - val boundary = boundaries.singleOrNull() ?: return@forEach + val candidates = boundariesByUid[activity.uid] ?: return@forEach + val boundary = candidates.singleOrNull { candidate -> + kotlin.math.abs( + candidate.inferredAt.toEpochMilli() - activity.occurredAt.toEpochMilli(), + ) <= MEMBERSHIP_CORRELATION_WINDOW_MILLIS + } ?: return@forEach val eventType = when (boundary.boundary) { MembershipBoundary.WITHDRAW -> MemberEventType.LEFT MembershipBoundary.JOIN -> @@ -330,70 +347,36 @@ internal object ActivityIngestion { observedAt = boundary.inferredAt, activityIds = listOf(activity.id), ) + candidates.remove(boundary) } } - private fun readNearbyInferredBoundaries( - db: SQLiteDatabase, - activity: ResolvedActivityCandidate, - ): List = db.rawQuery( - """ - SELECT id, uid, joined_at, left_at, joined_precision, left_precision - FROM membership_periods - WHERE uid = ? - AND ( - (joined_at IS NOT NULL AND joined_precision = ? AND ABS(joined_at - ?) <= ?) - OR - (left_at IS NOT NULL AND left_precision = ? AND ABS(left_at - ?) <= ?) - ) - """.trimIndent(), - arrayOf( - activity.uid.toString(), - EvidencePrecision.INFERRED.name, - activity.occurredAt.toEpochMilli().toString(), - MEMBERSHIP_CORRELATION_WINDOW_MILLIS.toString(), - EvidencePrecision.INFERRED.name, - activity.occurredAt.toEpochMilli().toString(), - MEMBERSHIP_CORRELATION_WINDOW_MILLIS.toString(), - ), - ).use { cursor -> - buildList { + private fun readInferredBoundaries(db: SQLiteDatabase): Map> = + db.rawQuery( + """ + SELECT id, uid, joined_at, left_at, joined_precision, left_precision + FROM membership_periods + WHERE (joined_at IS NOT NULL AND joined_precision = ?) + OR (left_at IS NOT NULL AND left_precision = ?) + """.trimIndent(), + arrayOf(EvidencePrecision.INFERRED.name, EvidencePrecision.INFERRED.name), + ).use { cursor -> + val boundaries = mutableMapOf>() while (cursor.moveToNext()) { val membershipPeriodId = cursor.getLong(0) val uid = cursor.getLong(1) - val joinedAt = cursor.getNullableLong(2) - val leftAt = cursor.getNullableLong(3) - if (joinedAt != null && - cursor.getString(4) == EvidencePrecision.INFERRED.name && - kotlin.math.abs(joinedAt - activity.occurredAt.toEpochMilli()) <= - MEMBERSHIP_CORRELATION_WINDOW_MILLIS - ) { - add( - InferredBoundary( - membershipPeriodId, - uid, - MembershipBoundary.JOIN, - Instant.ofEpochMilli(joinedAt), - ), - ) + val forMember = boundaries.getOrPut(uid, ::mutableListOf) + val joinedAt = cursor.getNullableInstant(2) + if (joinedAt != null && cursor.getString(4) == EvidencePrecision.INFERRED.name) { + forMember += InferredBoundary(membershipPeriodId, uid, MembershipBoundary.JOIN, joinedAt) } - if (leftAt != null && - cursor.getNullableString(5) == EvidencePrecision.INFERRED.name && - kotlin.math.abs(leftAt - activity.occurredAt.toEpochMilli()) <= - MEMBERSHIP_CORRELATION_WINDOW_MILLIS - ) { - add( - InferredBoundary( - membershipPeriodId, - uid, - MembershipBoundary.WITHDRAW, - Instant.ofEpochMilli(leftAt), - ), - ) + val leftAt = cursor.getNullableInstant(3) + if (leftAt != null && cursor.getNullableString(5) == EvidencePrecision.INFERRED.name) { + forMember += InferredBoundary(membershipPeriodId, uid, MembershipBoundary.WITHDRAW, leftAt) } } + boundaries } - } private data class ActivityCandidate( val id: Long, From 26ff7e9181d89cb1dd591775f3a3f738bfeffb43 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:24:57 +0900 Subject: [PATCH 022/125] fix: solve weekly rows only for the period-end roster --- .../app/management/WeeklyReportBuilder.kt | 61 +++++++++++++------ 1 file changed, 41 insertions(+), 20 deletions(-) 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 a979bef..2e8f1af 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt @@ -328,10 +328,7 @@ object WeeklyReportBuilder { } .filter { it.second in days } .toSet() - val rosterCutoff = minOf( - PlatoonPeriods.periodStartInstant(start.plusDays(7), zoneId), - asOf.plusMillis(1), - ) + val rosterCutoff = rosterCutoff(start, zoneId, asOf) val uids = sortedSnapshots.flatMap { it.members }.associateBy(SnapshotMember::uid).toMutableMap() val latestSnapshotAtByUid = buildMap { sortedSnapshots.forEach { snapshot -> @@ -373,15 +370,14 @@ object WeeklyReportBuilder { uids[uid] = SnapshotMember(uid, "#$uid", 0, 0, 0, 0, 0, 0) } } - val rosterSnapshot = sortedSnapshots.lastBefore(rosterCutoff) - val rosterAtPeriodEnd = applyMembershipEventsToRoster( - rosterSnapshot?.members?.map(SnapshotMember::uid).orEmpty().toSet(), - membershipEvents, - rosterSnapshot?.capturedAt, - rosterCutoff, - ) - - val rows = uids.values.map { latestKnown -> + // Weekly rows represent the roster that is active at the end of the selected reporting + // period (or the latest roster for an in-progress week). A member observed earlier in + // the week disappears as soon as a later complete roster records their withdrawal; the + // immutable membership event remains in the Join / Withdraw section. Rows are solved + // only for that roster because each row depends on its own member's evidence alone. + val rosterAtPeriodEnd = rosterAtCutoff(sortedSnapshots, membershipEvents, rosterCutoff) + + val rows = uids.values.filter { it.uid in rosterAtPeriodEnd }.map { latestKnown -> val derivedCells = days.map { day -> buildCell( uid = latestKnown.uid, @@ -455,13 +451,6 @@ object WeeklyReportBuilder { resolvedStandardTotals = standardResolution?.totals, gunsmokeAttemptsFloor = gunsmokeResolution?.attemptsFloor, ) - }.filter { row -> - // Weekly rows represent the roster that is active at the end of the - // selected reporting period (or the latest roster for an in-progress - // week). A member observed earlier in the week must disappear as soon - // as a later complete roster records their withdrawal; the immutable - // membership event remains available in the Join / Withdraw section. - row.uid in rosterAtPeriodEnd } .sortedWith( if (isGunsmoke) { @@ -476,6 +465,38 @@ object WeeklyReportBuilder { return Report(start, start.plusDays(6), isGunsmoke, days, rows) } + /** UIDs whose rows the report for [referenceDay] contains, without solving any row. */ + internal fun rosterAtPeriodEnd( + referenceDay: LocalDate, + zoneId: ZoneId, + snapshots: List, + membershipEvents: List, + asOf: Instant, + ): Set = rosterAtCutoff( + snapshots.sortedBy(PlatoonSnapshot::capturedAt), + membershipEvents, + rosterCutoff(PlatoonPeriods.weekStart(referenceDay), zoneId, asOf), + ) + + private fun rosterCutoff(periodStart: LocalDate, zoneId: ZoneId, asOf: Instant): Instant = minOf( + PlatoonPeriods.periodStartInstant(periodStart.plusDays(7), zoneId), + asOf.plusMillis(1), + ) + + private fun rosterAtCutoff( + sortedSnapshots: List, + membershipEvents: List, + cutoff: Instant, + ): Set { + val rosterSnapshot = sortedSnapshots.lastBefore(cutoff) + return applyMembershipEventsToRoster( + rosterSnapshot?.members?.map(SnapshotMember::uid).orEmpty().toSet(), + membershipEvents, + rosterSnapshot?.capturedAt, + cutoff, + ) + } + private fun buildCell( uid: Long, day: LocalDate, From ed9835fbc1f96d5d5822474d239cf56db48f4738 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:25:57 +0900 Subject: [PATCH 023/125] feat: auto-detect the restore kind of a selected backup --- .../PlatoonProfileRegistryIntegrationTest.kt | 39 +++++++++++++++++-- .../app/management/BackupFormatPolicy.kt | 21 ++++++++-- .../app/management/PlatoonBackupManager.kt | 34 ++++++++++++---- .../app/management/BackupFormatPolicyTest.kt | 25 ++++++++++++ 4 files changed, 104 insertions(+), 15 deletions(-) diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt index 4e3e41c..9a000f6 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt @@ -533,6 +533,38 @@ class PlatoonProfileRegistryIntegrationTest { assertEquals(999L, recovered.platoonId) } + @Test + fun autoDetectedRestoreOfAPlatoonOnlyBackupKeepsAppSettings() { + val registry = PlatoonProfileRegistry(context) + val profile = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_JAPAN, + PlatoonProfileData(101818u, "Larks", 0u, 0u), + ) + registry.setActive(profile.storageId) + val scope = PlatoonStorageScope(profile.storageId) + PlatoonRepository(context, scope).ingest( + Instant.parse("2026-08-24T02:00:00Z"), + listOf(member(11u, "Archived member")), + "archived.csv", + ) + val archive = ByteArrayOutputStream().also { output -> + PlatoonBackupManager(context, scope).export(output) + }.toByteArray() + MemberOrderPreferences(context, profile.storageId).write(listOf(11L, 12L)) + + assertEquals( + RestoredBackupKind.PLATOON, + PlatoonBackupManager.restoreSelected(context, ByteArrayInputStream(archive)), + ) + + assertEquals(listOf(11L, 12L), MemberOrderPreferences(context, profile.storageId).read()) + assertEquals( + "Archived member", + PlatoonRepository(context, scope).listMemberStatuses().single().name, + ) + } + @Test fun scopedCompleteBackupRecreatesItsProfileOnAFreshRegistry() { val registry = PlatoonProfileRegistry(context) @@ -560,10 +592,9 @@ class PlatoonProfileRegistryIntegrationTest { .edit().clear().commit() File(context.filesDir, "platoons").deleteRecursively() - PlatoonBackupManager.restoreSelected( - context, - ByteArrayInputStream(archive), - complete = true, + assertEquals( + RestoredBackupKind.COMPLETE, + PlatoonBackupManager.restoreSelected(context, ByteArrayInputStream(archive)), ) val restoredRegistry = PlatoonProfileRegistry(context) diff --git a/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt b/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt index 9cb9bcd..c7fae1e 100644 --- a/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/BackupFormatPolicy.kt @@ -19,11 +19,24 @@ internal object BackupFormatPolicy { } fun requireComplete(formatVersion: Int, hasSettings: Boolean) { - require( - formatVersion in setOf(COMPLETE_VERSION, LEGACY_SCOPED_VERSION, SCOPED_VERSION) && - hasSettings, - ) { + require(supportsComplete(formatVersion, hasSettings)) { "Backup does not contain complete app settings" } } + + fun supportsComplete(formatVersion: Int, hasSettings: Boolean): Boolean = + formatVersion in setOf(COMPLETE_VERSION, LEGACY_SCOPED_VERSION, SCOPED_VERSION) && hasSettings + + /** + * The restore an archive receives. A null [requestComplete] auto-detects: archives carrying + * restorable app settings are restored completely, anything else as Platoon data only. + */ + fun restoredKind( + formatVersion: Int, + hasSettings: Boolean, + requestComplete: Boolean?, + ): RestoredBackupKind = when (requestComplete ?: supportsComplete(formatVersion, hasSettings)) { + true -> RestoredBackupKind.COMPLETE + false -> RestoredBackupKind.PLATOON + } } 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 27dfe55..f0bb95e 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -484,12 +484,16 @@ class PlatoonBackupManager internal constructor( private const val RESTORE_PROFILE_FILE = "profile.pre_restore" private val SQLITE_HEADER = "SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) - /** Restores a scoped archive even when a fresh installation has no active profile. */ + /** + * Restores a scoped archive even when a fresh installation has no active profile. + * [complete] forces the kind; null restores app settings exactly when the archive + * carries restorable settings. Returns the kind that was restored. + */ fun restoreSelected( context: Context, input: InputStream, - complete: Boolean, - ) { + complete: Boolean? = null, + ): RestoredBackupKind { val appContext = context.applicationContext val stagingDirectory = File(appContext.cacheDir, "platoon-restore-selected").apply { check(mkdirs() || isDirectory) { "Unable to create the restore staging directory" } @@ -503,11 +507,18 @@ class PlatoonBackupManager internal constructor( PlatoonStorageScope(profile.storageId), ) val target = manager.managerFor(staged) - if (complete) { - manager.restoreStagedComplete(stagedDatabase, staged, target) - } else { - manager.restoreStagedPlatoon(stagedDatabase, staged, target) + val kind = BackupFormatPolicy.restoredKind( + staged.formatVersion, + staged.settings != null, + complete, + ) + when (kind) { + RestoredBackupKind.COMPLETE -> + manager.restoreStagedComplete(stagedDatabase, staged, target) + RestoredBackupKind.PLATOON -> + manager.restoreStagedPlatoon(stagedDatabase, staged, target) } + return kind } finally { stagedDatabase.delete() stagingDirectory.delete() @@ -803,6 +814,15 @@ class PlatoonBackupManager internal constructor( } } +/** What [PlatoonBackupManager.restoreSelected] restored. */ +enum class RestoredBackupKind { + /** The Platoon database only; app settings were left untouched. */ + PLATOON, + + /** The Platoon database together with app and Platoon settings. */ + COMPLETE, +} + // Class Name: InvalidBackupException // Role: Identifies failures that prove the selected backup is invalid. // Responsibilities: diff --git a/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt index 0df9565..9270efc 100644 --- a/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/BackupFormatPolicyTest.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Test @@ -55,4 +56,28 @@ class BackupFormatPolicyTest { ) } } + + @Test + fun `auto-detected restore is complete only when the archive carries settings`() { + assertEquals( + RestoredBackupKind.COMPLETE, + BackupFormatPolicy.restoredKind(BackupFormatPolicy.SCOPED_VERSION, true, null), + ) + assertEquals( + RestoredBackupKind.PLATOON, + BackupFormatPolicy.restoredKind(BackupFormatPolicy.SCOPED_VERSION, false, null), + ) + assertEquals( + RestoredBackupKind.PLATOON, + BackupFormatPolicy.restoredKind(BackupFormatPolicy.PLATOON_ONLY_VERSION, true, null), + ) + assertEquals( + RestoredBackupKind.COMPLETE, + BackupFormatPolicy.restoredKind(BackupFormatPolicy.SCOPED_VERSION, false, true), + ) + assertEquals( + RestoredBackupKind.PLATOON, + BackupFormatPolicy.restoredKind(BackupFormatPolicy.SCOPED_VERSION, true, false), + ) + } } From 9796585d4944688399ab80953dbfa744dafb1d60 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:27:52 +0900 Subject: [PATCH 024/125] fix: build weekly revisions from one read and refresh only affected weeks --- .../PlatoonProfileRegistryIntegrationTest.kt | 44 +++ .../gf2log/app/management/PlatoonDatabase.kt | 13 + .../app/management/PlatoonReadQueries.kt | 6 + .../app/management/PlatoonRepository.kt | 258 ++++++++++++------ .../app/management/WeeklyAnnotationStore.kt | 8 + 5 files changed, 250 insertions(+), 79 deletions(-) diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt index 9a000f6..20ff32e 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt @@ -452,6 +452,50 @@ class PlatoonProfileRegistryIntegrationTest { assertEquals(null, WeeklyCutlinePreferences(context, deleted.storageId).read().dailyMerit) } + @Test + fun memberEditsRefreshOnlyTheWeeksThatShowTheMember() { + val profile = PlatoonProfileRegistry(context).upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(5u, "Weeks", 0u, 0u), + ) + val scope = PlatoonStorageScope(profile.storageId) + val repository = PlatoonRepository(context, scope) + val firstCapture = Instant.parse("2026-08-04T12:00:00Z") + repository.ingest(firstCapture, listOf(member(1u, "Stayer")), "first.csv") + repository.ingest( + firstCapture.plusSeconds(7L * 24L * 60L * 60L), + listOf(member(1u, "Stayer"), member(2u, "Newcomer")), + "second.csv", + ) + val zone = dev.gf2log.app.settings.GameTimeZonePreferences.get(context, profile.storageId) + val firstWeek = PlatoonPeriods.weekStart(PlatoonPeriods.gameDay(firstCapture, zone)) + val secondWeek = firstWeek.plusDays(7) + // A stale latest revision makes any rebuild of the first week observable. + PlatoonRepository.withExclusiveDatabase(scope) { + PlatoonDatabase(context, scope.databaseName).use { database -> + database.recordWeeklyReportHistory( + periodStartEpochDay = firstWeek.toEpochDay(), + recordedAt = Instant.now(), + fingerprint = "0".repeat(64), + payload = byteArrayOf(1), + clearActiveOnChange = false, + ) + } + } + val firstWeekRevisions = repository.listWeeklyReportHistory(firstWeek).size + val secondWeekRevisions = repository.listWeeklyReportHistory(secondWeek).size + + assertTrue(repository.updateMember(2L, "Renamed", "")) + + assertEquals(firstWeekRevisions, repository.listWeeklyReportHistory(firstWeek).size) + assertEquals(secondWeekRevisions + 1, repository.listWeeklyReportHistory(secondWeek).size) + assertEquals( + "Renamed", + repository.buildWeeklyTableRevision(secondWeek, zone).memberNamesByUid[2L], + ) + } + @Test fun pendingDeletionRecoveryCannotDeadlockWithAnExclusiveRestore() { val registry = PlatoonProfileRegistry(context) 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 3cfb0fc..3fcd323 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -61,6 +61,19 @@ internal class PlatoonDatabase private constructor( @Synchronized internal fun runInTransaction(block: () -> T): T = writableDatabase.inTransaction(block) + /** Runs several reads against one state: every writer is serialized on this helper. */ + @Synchronized + internal fun readConsistently(block: () -> T): T = block() + + @Synchronized + fun membershipPeriodOwner(membershipPeriodId: Long): Long? = + PlatoonReadQueries.membershipPeriodOwner(readableDatabase, membershipPeriodId) + + /** Period starts of manual weekly notes that cite one of [uid]'s membership events. */ + @Synchronized + fun weeklyNotePeriodsCitingMember(uid: Long): Set = + WeeklyAnnotationStore.notePeriodsCitingMember(readableDatabase, uid) + @Synchronized fun ingestSnapshot( snapshot: PlatoonSnapshot, diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt index 0659034..0d08932 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt @@ -212,6 +212,12 @@ internal object PlatoonReadQueries { return days.distinct() } + fun membershipPeriodOwner(db: SQLiteDatabase, membershipPeriodId: Long): Long? = + db.rawQuery( + "SELECT uid FROM membership_periods WHERE id = ?", + arrayOf(membershipPeriodId.toString()), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + fun count(db: SQLiteDatabase, table: String): Long = db.rawQuery("SELECT COUNT(*) FROM $table", null).use { cursor -> cursor.moveToFirst() 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 1bbd386..0c27bd2 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -177,20 +177,18 @@ internal class PlatoonRepository( fun listEvents( from: Instant, until: Instant, - fromDate: java.time.LocalDate, - untilDate: java.time.LocalDate, + fromDate: LocalDate, + untilDate: LocalDate, ): List = access { it.listEvents(from, until, fromDate, untilDate) } fun listDailyPatrolFacts(from: Instant, until: Instant): List = access { it.listDailyPatrolFacts(from, until) } fun updateMember(uid: Long, name: String, note: String): Boolean = - access { it.updateMember(uid, name, note) }.also { changed -> - if (changed) recordAllLiveWeeklyReports() - } + refreshingWeeksOf(uid) { access { it.updateMember(uid, name, note) } } - fun deleteMember(uid: Long): Boolean { - val deleted = withExclusiveDatabase(storageScope) { + fun deleteMember(uid: Long): Boolean = refreshingWeeksOf(uid) { + withExclusiveDatabase(storageScope) { val order = MemberOrderPreferences(appContext, storageScope.storageId) val previousOrder = order.read() val updatedOrder = previousOrder.filterNot { it == uid } @@ -216,8 +214,6 @@ internal class PlatoonRepository( throw error } } - if (deleted) recordAllLiveWeeklyReports() - return deleted } fun updateMembershipPeriod( @@ -225,12 +221,14 @@ internal class PlatoonRepository( joined: MembershipBoundaryValue, left: MembershipBoundaryValue?, note: String, - ): Boolean = access { it.updateMembershipPeriod(membershipPeriodId, joined, left, note) } - .also { changed -> if (changed) recordAllLiveWeeklyReports() } + ): Boolean = refreshingWeeksOfPeriodOwner(membershipPeriodId) { + access { it.updateMembershipPeriod(membershipPeriodId, joined, left, note) } + } fun deleteMembershipPeriod(membershipPeriodId: Long): Boolean = - access { it.deleteMembershipPeriod(membershipPeriodId) } - .also { changed -> if (changed) recordAllLiveWeeklyReports() } + refreshingWeeksOfPeriodOwner(membershipPeriodId) { + access { it.deleteMembershipPeriod(membershipPeriodId) } + } fun addWithdrawnMember( uid: Long, @@ -238,16 +236,18 @@ internal class PlatoonRepository( joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue, note: String, - ): Boolean = access { it.addWithdrawnMember(uid, name, joined, withdrew, note) } - .also { changed -> if (changed) recordAllLiveWeeklyReports() } + ): Boolean = refreshingWeeksOf(uid) { + access { it.addWithdrawnMember(uid, name, joined, withdrew, note) } + } fun addMembershipPeriod( uid: Long, joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue?, note: String, - ): Boolean = access { it.addMembershipPeriod(uid, joined, withdrew, note) } - .also { changed -> if (changed) recordAllLiveWeeklyReports() } + ): Boolean = refreshingWeeksOf(uid) { + access { it.addMembershipPeriod(uid, joined, withdrew, note) } + } fun addWeeklyNote(periodStartEpochDay: Long, gameDayEpochDay: Long, text: String): Long = access { it.addWeeklyNote(periodStartEpochDay, gameDayEpochDay, text) }.also { @@ -267,12 +267,18 @@ internal class PlatoonRepository( fun listWeeklyOverrides(periodStartEpochDay: Long): List = access { it.listWeeklyOverrides(periodStartEpochDay) } + /** The displayed report (a restored revision when selected) without writing history. */ fun buildWeeklyReport( referenceDay: LocalDate, zoneId: ZoneId, asOf: Instant = Instant.now(), - ): WeeklyReportBuilder.Report = buildWeeklyTableRevision(referenceDay, zoneId, asOf).report + ): WeeklyReportBuilder.Report = readWeeklyTableRevision(referenceDay, zoneId, asOf).report + /** + * The weekly screen's load: returns the displayed revision and records the live projection + * it was derived from, so the history list includes what the user saw. A stored selection + * that no longer decodes is cleared and the live projection is shown instead. + */ fun buildWeeklyTableRevision( referenceDay: LocalDate, zoneId: ZoneId, @@ -280,16 +286,29 @@ internal class PlatoonRepository( ): WeeklyTableRevision { val live = buildLiveWeeklyRevision(referenceDay, zoneId, asOf) recordHistory(live, Instant.now(), clearActiveOnChange = false) - val activePayload = access { + return activeRevision(live) ?: live.also { + access { database -> + database.clearActiveWeeklyReportHistory(live.report.periodStart.toEpochDay()) + } + } + } + + /** Like [buildWeeklyTableRevision] but strictly read-only. */ + fun readWeeklyTableRevision( + referenceDay: LocalDate, + zoneId: ZoneId, + asOf: Instant = Instant.now(), + ): WeeklyTableRevision { + val live = buildLiveWeeklyRevision(referenceDay, zoneId, asOf) + return activeRevision(live) ?: live + } + + /** The selected historical revision, [live] when none is selected, or null if undecodable. */ + private fun activeRevision(live: WeeklyTableRevision): WeeklyTableRevision? { + val payload = access { it.activeWeeklyReportHistoryPayload(live.report.periodStart.toEpochDay()) } ?: return live - return runCatching { WeeklyReportHistoryCodec.decodeRevision(activePayload) } - .getOrElse { - access { database -> - database.clearActiveWeeklyReportHistory(live.report.periodStart.toEpochDay()) - } - live - } + return runCatching { WeeklyReportHistoryCodec.decodeRevision(payload) }.getOrNull() } private fun buildLiveWeeklyRevision( @@ -297,65 +316,91 @@ internal class PlatoonRepository( zoneId: ZoneId, asOf: Instant, ): WeeklyTableRevision { - val report = buildLiveWeeklyReport(referenceDay, zoneId, asOf) - val periodStart = report.periodStart - val statuses = listMemberStatuses() - val membershipEvents = listEvents( - periodStart.atStartOfDay(zoneId).toInstant(), - periodStart.plusDays(7).atStartOfDay(zoneId).toInstant(), - periodStart, - periodStart.plusDays(7), - ).filter { + val inputs = readRevisionInputs(PlatoonPeriods.weekStart(referenceDay), zoneId, asOf) + val report = WeeklyReportBuilder.build( + referenceDay = referenceDay, + zoneId = zoneId, + snapshots = inputs.snapshots, + membershipEvents = inputs.reportEvents, + overrides = inputs.overrides, + dailyPatrolFacts = inputs.dailyPatrolFacts, + asOf = asOf, + ) + val membershipEvents = inputs.revisionEvents.filter { it.type in MembershipEventPresentation.displayedTypes && it.source in MembershipEventPresentation.displayedSources }.take(MAX_REVISION_EVENTS) .map { it.copy(note = it.note.boundedUtf8(MAX_REVISION_NOTE_BYTES)) } - val relevantNameUids = ( - report.members.asSequence().map { it.uid } + membershipEvents.asSequence().map { it.uid } - ).toSet() + val reportUids = report.members.mapTo(mutableSetOf()) { it.uid } + val relevantNameUids = reportUids + membershipEvents.map { it.uid } return ManagementConsistencyAudit.requireValid(WeeklyTableRevision( report = report, membershipEvents = membershipEvents, - notes = listWeeklyNotes(periodStart.toEpochDay()) + notes = inputs.notes .filterNot(WeeklyNote::isAutomatic) .take(MAX_REVISION_NOTES) .map { it.copy(text = it.text.boundedUtf8(MAX_REVISION_NOTE_BYTES)) }, - memberNamesByUid = statuses.asSequence() + memberNamesByUid = inputs.statuses.asSequence() .filter { it.uid in relevantNameUids } .take(MAX_REVISION_NAMES) .associate { it.uid to it.name.boundedUtf8(MAX_REVISION_NAME_BYTES) }, - memberPrivateNotesByUid = statuses.asSequence() - .filter { status -> report.members.any { it.uid == status.uid } } + memberPrivateNotesByUid = inputs.statuses.asSequence() + .filter { it.uid in reportUids } .associate { it.uid to it.note.boundedUtf8(MAX_REVISION_PRIVATE_NOTE_BYTES) }, )) } - private fun buildLiveWeeklyReport( - referenceDay: LocalDate, + /** Every input of one weekly revision, read under a single consistent database state. */ + private fun readRevisionInputs( + periodStart: LocalDate, zoneId: ZoneId, asOf: Instant, - ): WeeklyReportBuilder.Report { - val periodStart = PlatoonPeriods.weekStart(referenceDay) + ): RevisionInputs = access { database -> + database.readConsistently { + val roster = readRosterInputs(database, periodStart, zoneId, asOf) + RevisionInputs( + snapshots = roster.snapshots, + reportEvents = roster.reportEvents, + revisionEvents = roster.revisionEvents, + overrides = database.listWeeklyOverrides(periodStart.toEpochDay()), + dailyPatrolFacts = database.listDailyPatrolFacts( + PlatoonPeriods.periodStartInstant(periodStart, zoneId), + PlatoonPeriods.periodStartInstant(periodStart.plusDays(7), zoneId), + ), + notes = database.listWeeklyNotes(periodStart.toEpochDay()), + statuses = database.listMemberStatuses(), + ) + } + } + + private fun readRosterInputs( + database: PlatoonDatabase, + periodStart: LocalDate, + zoneId: ZoneId, + asOf: Instant, + ): RosterInputs { val from = PlatoonPeriods.periodStartInstant(periodStart, zoneId) val until = PlatoonPeriods.periodStartInstant(periodStart.plusDays(7), zoneId) - val snapshots = listSnapshotsForPeriod(from, until) + val snapshots = database.listSnapshotsForPeriod(from, until) val membershipEventFrom = minOf( from, snapshots.minOfOrNull(PlatoonSnapshot::capturedAt) ?: Instant.EPOCH, ) - return WeeklyReportBuilder.build( - referenceDay = referenceDay, - zoneId = zoneId, + return RosterInputs( snapshots = snapshots, - membershipEvents = listEvents( + reportEvents = database.listEvents( membershipEventFrom, minOf(until, asOf.plusMillis(1)), PlatoonPeriods.gameDay(membershipEventFrom, zoneId), periodStart.plusDays(7), ), - overrides = listWeeklyOverrides(periodStart.toEpochDay()), - dailyPatrolFacts = listDailyPatrolFacts(from, until), - asOf = asOf, + // The Join / Withdraw section uses calendar-day bounds rather than 05:00 resets. + revisionEvents = database.listEvents( + periodStart.atStartOfDay(zoneId).toInstant(), + periodStart.plusDays(7).atStartOfDay(zoneId).toInstant(), + periodStart, + periodStart.plusDays(7), + ), ) } @@ -420,27 +465,69 @@ internal class PlatoonRepository( } } - // Function Name: recordAllLiveWeeklyReports - // Description: - // - Refreshes every derived weekly revision after a primary-data mutation. - // - Keeps committed member or packet data authoritative when optional history maintenance fails. - // Parameters: - // - failFast: Propagates failures for explicit maintenance operations such as timezone rebuilds. - private fun recordAllLiveWeeklyReports(failFast: Boolean = false) { + /** Refreshes every derived weekly revision after a primary-data mutation. */ + private fun recordAllLiveWeeklyReports() { val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) + weeklyPeriodStarts(zone).forEach { periodStart -> + recordLiveWeeklyRevisionSafely(periodStart, zone) + } + } + + private fun weeklyPeriodStarts(zone: ZoneId): List = WeeklyReportRange.periodStarts(access { it.listWeeklyEvidenceDays(zone) }) - .forEach { periodStart -> - if (failFast) { - val now = Instant.now() - recordHistory( - buildLiveWeeklyRevision(periodStart, zone, now), - now, - clearActiveOnChange = true, - ) - } else { - recordLiveWeeklyRevisionSafely(periodStart, zone) - } + + /** + * Runs a mutation confined to one member's rows and refreshes only the weeks whose revision + * can show that member before or after it: weeks whose period-end roster or Join / Withdraw + * section contains the member, or whose notes cite the member's events. Every other row, + * name, and note of a revision depends on other members' evidence alone. If the pre-scan + * fails, every week is refreshed so the mutation stays authoritative. + */ + private fun refreshingWeeksOf(uid: Long?, mutation: () -> Boolean): Boolean { + val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) + val asOf = Instant.now() + val before = uid?.let { runCatching { weeksShowingMember(it, zone, asOf) }.getOrNull() } + if (!mutation()) return false + try { + val weeks = weeklyPeriodStarts(zone) + val affected = if (uid == null || before == null) { + weeks + } else { + val current = weeks.toSet() + (before.filter { it in current } + weeksShowingMember(uid, zone, asOf)).distinct() + .sorted() + } + affected.forEach { recordLiveWeeklyRevisionSafely(it, zone) } + } catch (error: Exception) { + Log.e(TAG, "Unable to scope weekly history refresh; refreshing every week", error) + recordAllLiveWeeklyReports() + } + return true + } + + private fun refreshingWeeksOfPeriodOwner( + membershipPeriodId: Long, + mutation: () -> Boolean, + ): Boolean = refreshingWeeksOf( + runCatching { access { it.membershipPeriodOwner(membershipPeriodId) } }.getOrNull(), + mutation, + ) + + private fun weeksShowingMember(uid: Long, zone: ZoneId, asOf: Instant): Set { + val citingNotes = access { it.weeklyNotePeriodsCitingMember(uid) } + return weeklyPeriodStarts(zone).filterTo(citingNotes.toMutableSet()) { periodStart -> + val inputs = access { database -> + database.readConsistently { readRosterInputs(database, periodStart, zone, asOf) } } + inputs.revisionEvents.any { it.uid == uid } || + uid in WeeklyReportBuilder.rosterAtPeriodEnd( + periodStart, + zone, + inputs.snapshots, + inputs.reportEvents, + asOf, + ) + } } private fun recordHistory( @@ -465,13 +552,10 @@ internal class PlatoonRepository( recordLiveWeeklyRevisionSafely(periodStart, zone) } - // Function Name: recordLiveWeeklyRevisionSafely - // Description: - // - Records a derived table revision without changing the result of an already committed mutation. - // - Clears a restored projection on failure so the next screen render uses authoritative live data. - // Parameters: - // - periodStart: Sunday key of the weekly table to refresh. - // - zone: Persisted game timezone used by every date boundary in the projection. + /** + * Records a derived revision without changing the result of an already committed mutation. + * On failure the restored selection is cleared so the next render uses live data. + */ private fun recordLiveWeeklyRevisionSafely(periodStart: LocalDate, zone: ZoneId) { try { val now = Instant.now() @@ -517,6 +601,22 @@ internal class PlatoonRepository( val sourceFile: String?, ) + private class RosterInputs( + val snapshots: List, + val reportEvents: List, + val revisionEvents: List, + ) + + private class RevisionInputs( + val snapshots: List, + val reportEvents: List, + val revisionEvents: List, + val overrides: List, + val dailyPatrolFacts: List, + val notes: List, + val statuses: List, + ) + companion object { const val RETAINED_CSV_DIRECTORY = "guild-members" private const val MAX_REVISION_EVENTS = 512 diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt index 192f116..5761ad3 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt @@ -67,6 +67,14 @@ internal object WeeklyAnnotationStore { if (cursor.moveToFirst()) LocalDate.ofEpochDay(cursor.getLong(0)) else null } + fun notePeriodsCitingMember(db: SQLiteDatabase, uid: Long): Set = db.rawQuery( + "SELECT DISTINCT period_start FROM weekly_notes WHERE is_automatic = 0 AND event_id IN " + + "(SELECT id FROM member_events WHERE uid = ?)", + arrayOf(uid.toString()), + ).use { cursor -> + buildSet { while (cursor.moveToNext()) add(LocalDate.ofEpochDay(cursor.getLong(0))) } + } + fun deleteWeeklyNote(db: SQLiteDatabase, id: Long): Boolean = db.delete( "weekly_notes", From 961f0baad407ef3c8b84f77df651ad2b96fc69a8 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:30:14 +0900 Subject: [PATCH 025/125] feat: redesign Home, Settings, packet detail, and onboarding - Home observes typed capture status through listeners instead of polling English text: localized notices, a one-time checklist, client install state, and one Stop action while busy. Notification permission is requested when capture starts instead of at launch. - Packet history uses Recent/Saved tabs with long-press selection, confirmed deletion, and off-main-thread loading. - Roster CSV import moves into its own flow with a structured preview. - Settings groups Platoon, display, capture, backup, Discord, and about; both backup kinds and restore live there, and diagnostics open as a copyable sheet. The welcome guide can be replayed. - New-Platoon admission prompts follow the pending store directly, keep the chosen server across recreation, and close when resolved. - Packet detail toggles between table and raw text in both directions. - Remove the legacy tree-walking styler and toast helpers. --- .../main/java/dev/gf2log/app/BaseActivity.kt | 49 +- .../main/java/dev/gf2log/app/CsvImportFlow.kt | 334 ++++ .../main/java/dev/gf2log/app/MainActivity.kt | 1645 ++++++----------- .../dev/gf2log/app/MemberDetailActivity.kt | 8 +- .../dev/gf2log/app/MemberOrderActivity.kt | 4 +- .../java/dev/gf2log/app/ModernFeedback.kt | 35 - app/src/main/java/dev/gf2log/app/ModernUi.kt | 629 ------- .../java/dev/gf2log/app/OnboardingActivity.kt | 161 +- .../java/dev/gf2log/app/OptionsActivity.kt | 1052 ++++------- .../dev/gf2log/app/PacketHistoryActivity.kt | 356 ++-- .../app/PendingPlatoonAdmissionPrompt.kt | 260 +-- .../java/dev/gf2log/app/PlatoonActivity.kt | 8 +- .../dev/gf2log/app/PlatoonCsvImportPrompt.kt | 406 ++-- .../java/dev/gf2log/app/PrimaryActionStyle.kt | 33 - .../java/dev/gf2log/app/PrimaryNavigation.kt | 26 +- .../gf2log/app/SimpleItemSelectedListener.kt | 14 - .../gf2log/app/SnapshotComparisonActivity.kt | 1 - .../main/java/dev/gf2log/app/WeeklyDialogs.kt | 5 +- .../dev/gf2log/app/WeeklyReportActivity.kt | 8 +- .../dev/gf2log/app/WeeklySettingsActivity.kt | 4 +- .../gf2log/app/WeeklyTableHistoryActivity.kt | 1 - .../app/WeeklyTableHistoryPreviewActivity.kt | 4 +- .../main/java/dev/gf2log/app/ui/Controls.kt | 241 +++ app/src/main/java/dev/gf2log/app/ui/Screen.kt | 2 +- .../main/java/dev/gf2log/app/ui/StateViews.kt | 3 +- app/src/main/java/dev/gf2log/app/ui/Views.kt | 17 +- app/src/main/res/values-ko/strings.xml | 80 +- app/src/main/res/values/ids.xml | 2 - app/src/main/res/values/strings.xml | 97 +- 29 files changed, 2143 insertions(+), 3342 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/CsvImportFlow.kt delete mode 100644 app/src/main/java/dev/gf2log/app/ModernFeedback.kt delete mode 100644 app/src/main/java/dev/gf2log/app/ModernUi.kt delete mode 100644 app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt delete mode 100644 app/src/main/java/dev/gf2log/app/SimpleItemSelectedListener.kt create mode 100644 app/src/main/java/dev/gf2log/app/ui/Controls.kt diff --git a/app/src/main/java/dev/gf2log/app/BaseActivity.kt b/app/src/main/java/dev/gf2log/app/BaseActivity.kt index 53083f8..ff1023a 100644 --- a/app/src/main/java/dev/gf2log/app/BaseActivity.kt +++ b/app/src/main/java/dev/gf2log/app/BaseActivity.kt @@ -6,6 +6,8 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.Lifecycle +import dev.gf2log.app.capture.PendingPlatoonAdmissionStore /** * Applies the persisted language and appearance before views exist, draws edge to edge, and @@ -36,6 +38,29 @@ abstract class BaseActivity : ComponentActivity() { protected open fun supportsPendingPlatoonAdmission(): Boolean = true + private var pendingCandidates = 0 + private val admissionListener: (List) -> Unit = { candidates -> + PendingPlatoonAdmissionPrompt.onCandidatesChanged(this, candidates) + // A confirmed candidate becomes the active Platoon; let the screen rebind to it. + if (candidates.size < pendingCandidates && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + onScreenResumed() + } + pendingCandidates = candidates.size + } + + override fun onStart() { + super.onStart() + if (supportsPendingPlatoonAdmission() && OnboardingPreferences.isCompleted(this)) { + PendingPlatoonAdmissionStore.addListener(admissionListener) + } + } + + override fun onStop() { + PendingPlatoonAdmissionStore.removeListener(admissionListener) + PendingPlatoonAdmissionPrompt.dismiss() + super.onStop() + } + final override fun onResume() { super.onResume() if ( @@ -45,31 +70,9 @@ abstract class BaseActivity : ComponentActivity() { recreate() return } - onScreenResumed() - if (supportsPendingPlatoonAdmission() && OnboardingPreferences.isCompleted(this)) { - PendingPlatoonAdmissionPrompt.showIfNeeded(this) - } + if (!isFinishing) onScreenResumed() } /** Called from onResume only when this instance remains current. */ protected open fun onScreenResumed() = Unit - - /** Screens not yet composed from shared components keep the legacy tree styling pass. */ - protected open val legacyStyling: Boolean = true - - override fun onPostCreate(savedInstanceState: Bundle?) { - super.onPostCreate(savedInstanceState) - if (!legacyStyling) return - val content = findViewById(android.R.id.content) - androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(content) { view, insets -> - val bars = insets.getInsets( - androidx.core.view.WindowInsetsCompat.Type.systemBars() or - androidx.core.view.WindowInsetsCompat.Type.ime(), - ) - view.setPadding(bars.left, bars.top, bars.right, bars.bottom) - androidx.core.view.WindowInsetsCompat.CONSUMED - } - ModernUi.prepareContent(window.decorView) - window.decorView.post { if (!isFinishing) ModernUi.prepareContent(window.decorView) } - } } diff --git a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt new file mode 100644 index 0000000..8ed32b1 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt @@ -0,0 +1,334 @@ +package dev.gf2log.app + +import android.net.Uri +import android.os.Bundle +import android.view.Gravity +import androidx.activity.result.contract.ActivityResultContracts +import dev.gf2log.app.capture.CaptureStatus +import dev.gf2log.app.management.CsvImportCheckpointManager +import dev.gf2log.app.management.CsvImportPreviewAnalyzer +import dev.gf2log.app.management.PlatoonCsvImportStore +import dev.gf2log.app.management.PlatoonProfile +import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.text + +/** + * Roster CSV import: choose the destination Platoon, pick files, preview their impact, then apply + * behind an automatic checkpoint. Payload 21917 carries no Platoon identity, so the destination is + * always an explicit user choice that is re-verified before anything is retained. + */ +internal class CsvImportFlow( + private val activity: BaseActivity, + private val work: BackgroundWork, + private val boundScope: () -> PlatoonStorageScope?, + private val onProgress: (CharSequence?) -> Unit, +) { + private var pickerTarget: String? = null + private var launchPickerWhenReady = false + private var pending: PendingImport? = null + + private val picker = activity.registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + val target = pickerTarget?.let(::resolveTarget) + if (uris.isNullOrEmpty()) { + pickerTarget = null + return@registerForActivityResult + } + if (target == null) { + pickerTarget = null + Feedback.show(activity, R.string.csv_import_target_changed, long = true) + return@registerForActivityResult + } + prepare(uris.distinct(), target) + } + + fun saveState(outState: Bundle) { + pickerTarget?.let { outState.putString(STATE_TARGET, it) } + outState.putBoolean(STATE_LAUNCH, launchPickerWhenReady) + } + + fun restoreState(state: Bundle?) { + pickerTarget = state?.getString(STATE_TARGET) + launchPickerWhenReady = state?.getBoolean(STATE_LAUNCH) == true + } + + /** Called once the hosting screen is bound to its final scope. */ + fun onScreenReady() { + if (!launchPickerWhenReady) return + launchPickerWhenReady = false + launchPicker() + } + + fun start() { + if (CaptureStatus.isRunning) { + Feedback.show(activity, R.string.stop_capture_before_csv_import, long = true) + return + } + PlatoonCsvImportPrompt.show(activity) { profile -> beginFor(profile) } + } + + fun undo() { + if (CaptureStatus.isRunning) { + Feedback.show(activity, R.string.stop_capture_before_csv_import, long = true) + return + } + val scope = boundScope() ?: run { + Feedback.show(activity, R.string.no_platoon_detected_detail, long = true) + return + } + work.run(task = { CsvImportCheckpointManager(activity, scope).canUndo() }) { result -> + if (!result.getOrDefault(false)) { + Feedback.show(activity, R.string.no_csv_import_checkpoint) + return@run + } + Dialogs.confirm(activity, R.string.undo_last_csv_import, R.string.undo_last_csv_import_warning, R.string.undo) { + onProgress(activity.getString(R.string.csv_import_undoing)) + work.run(task = { CsvImportCheckpointManager(activity, scope).restore() }) { restore -> + onProgress(null) + Feedback.show( + activity, + if (restore.isSuccess) R.string.csv_import_undone else R.string.csv_import_undo_failed, + long = restore.isFailure, + ) + } + } + } + } + + private fun beginFor(profile: PlatoonProfile) { + if (!PlatoonProfileRegistry(activity).setActive(profile.storageId)) { + Feedback.show(activity, R.string.csv_import_target_changed, long = true) + return + } + pickerTarget = profile.storageId + if (boundScope()?.storageId == profile.storageId) { + launchPicker() + } else { + // Rebind every scoped helper to the newly selected Platoon before reading files. + launchPickerWhenReady = true + activity.recreate() + } + } + + private fun launchPicker() { + if (pickerTarget?.let(::resolveTarget) == null) { + pickerTarget = null + return + } + picker.launch(arrayOf("text/csv", "text/comma-separated-values", "text/plain")) + } + + private fun resolveTarget(storageId: String): Target? { + val active = PlatoonProfileRegistry(activity).active() ?: return null + val scope = PlatoonStorageScope(storageId) + if (active.storageId != storageId || active.legacy || boundScope() != scope) return null + return Target(scope, active) + } + + private fun prepare(sources: List, target: Target) { + onProgress(activity.getString(R.string.csv_import_preparing_preview)) + work.run( + task = { + // Recover an interrupted prior import before reading the preview baseline. + CsvImportCheckpointManager(activity, target.scope) + require(sources.size <= MAX_FILES) { "Too many Platoon CSV files were selected" } + val directory = target.scope.retainedCsvDirectory(activity) + val store = PlatoonCsvImportStore(directory) + var bytes = 0L + val selected = sources.map { source -> + DocumentAccess.openInput(activity.contentResolver, source).use(store::prepare).also { + bytes += it.byteCount + require(bytes <= MAX_BYTES) { "Selected Platoon CSV files exceed the total import size limit" } + } + } + val unique = selected.distinctBy(PlatoonCsvImportStore.PreparedImport::fileName) + val repository = PlatoonRepository(activity, target.scope) + repository.reconcileRetainedCsvFiles(directory) + val duplicates = CsvImportPreviewAnalyzer.duplicateFileNames(unique, repository.representedSnapshotSources()) + val analyzed = CsvImportPreviewAnalyzer.analyze( + prepared = unique, + duplicateFileNames = duplicates, + existingMembers = repository.listMemberStatuses(), + latestSnapshot = repository.listSnapshots(limit = 1).firstOrNull(), + ) + PendingImport( + target = target, + prepared = unique, + duplicateFileNames = duplicates, + preview = analyzed.copy( + validatedFiles = selected.size, + duplicateFiles = analyzed.duplicateFiles + selected.size - unique.size, + totalBytes = bytes, + ), + ) + }, + ) { result -> + onProgress(null) + result.fold( + onSuccess = { + pending = it + showPreview(it) + }, + onFailure = { + pickerTarget = null + Feedback.show(activity, R.string.status_platoon_csv_import_failed, long = true) + }, + ) + } + } + + private fun showPreview(import: PendingImport) { + val preview = import.preview + val profile = import.target.profile + val range = if (preview.firstCapture == null || preview.lastCapture == null) { + activity.getString(R.string.none) + } else { + val zone = java.time.ZoneId.systemDefault() + activity.getString( + R.string.csv_import_capture_range, + Labels.format(Labels.DATE_TIME, preview.firstCapture, zone), + Labels.format(Labels.DATE_TIME, preview.lastCapture, zone), + ) + } + val facts = listOf( + R.string.csv_preview_files to preview.validatedFiles.toString(), + R.string.csv_preview_already_present to preview.duplicateFiles.toString(), + R.string.csv_preview_historical to preview.historicalFiles.toString(), + R.string.csv_preview_members to preview.uniqueMembers.toString(), + R.string.csv_preview_new_members to preview.newMembers.toString(), + R.string.csv_preview_name_changes to preview.nameDifferences.toString(), + R.string.csv_preview_joins to preview.potentialJoins.toString(), + R.string.csv_preview_withdrawals to preview.potentialWithdrawals.toString(), + R.string.csv_preview_size to activity.getString(R.string.kib, preview.totalBytes / 1024), + R.string.csv_preview_range to range, + ) + val content = Dialogs.content(activity) { + addView(activity.panel(emphasized = true, padding = 12) { + addView(activity.text(activity.getString(R.string.csv_import_destination_label), TextRole.LABEL), activity.lp()) + addView(activity.text(profile.platoonName, TextRole.SUBTITLE), activity.lp(top = 2)) + addView(activity.text(Labels.profileIdentity(activity, profile), TextRole.CAPTION), activity.lp()) + addView(activity.text(activity.getString(R.string.csv_import_destination_warning), TextRole.CAPTION) { + setTextColor(activity.getColor(R.color.accent_text)) + }, activity.lp(top = 6)) + }, activity.lp()) + facts.forEach { (label, value) -> + addView(activity.row(Gravity.TOP) { + minimumHeight = (32 * resources.displayMetrics.density).toInt() + addView(activity.text(activity.getString(label), TextRole.SECONDARY), activity.lp(width = 0, weight = 1f)) + addView(activity.text(value, TextRole.BODY) { gravity = Gravity.END }, activity.lp(WRAP, WRAP, start = 12)) + }, activity.lp(top = 4)) + } + addView(activity.text(activity.getString(R.string.csv_import_no_changes_yet), TextRole.CAPTION), activity.lp(top = 8)) + } + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.csv_import_preview_title) + .setView(content) + .setNegativeButton(R.string.cancel) { _, _ -> clear() } + .setOnCancelListener { clear() } + .setPositiveButton(R.string.import_action) { _, _ -> apply(import) }, + ) + } + + private fun clear() { + pending = null + pickerTarget = null + } + + private fun apply(import: PendingImport) { + val target = resolveTarget(import.target.scope.storageId) + if (pending !== import || CaptureStatus.isRunning || target == null || + target.profile.client != import.target.profile.client || + target.profile.platoonId != import.target.profile.platoonId + ) { + clear() + Feedback.show( + activity, + if (CaptureStatus.isRunning) R.string.stop_capture_before_csv_import else R.string.csv_import_target_changed, + long = true, + ) + return + } + pending = null + onProgress(activity.getString(R.string.csv_import_applying)) + work.run( + task = { + val directory = import.target.scope.retainedCsvDirectory(activity) + val store = PlatoonCsvImportStore(directory) + val checkpoint = CsvImportCheckpointManager(activity, import.target.scope) + var checkpointCreated = false + runCatching { + checkpoint.create( + import.prepared.filterNot { it.fileName in import.duplicateFileNames }.mapTo(mutableSetOf()) { it.fileName }, + ) + checkpointCreated = true + var retained = 0 + var duplicates = 0 + import.prepared.forEach { prepared -> + if (store.retain(prepared).duplicate) duplicates += 1 else retained += 1 + } + val imported = PlatoonRepository(activity, import.target.scope).reconcileRetainedCsvFiles(directory) + checkpoint.seal() + Summary(retained, duplicates, imported) + }.recoverCatching { failure -> + if (checkpointCreated) { + runCatching(checkpoint::rollbackFailedImport).exceptionOrNull()?.let(failure::addSuppressed) + } + throw failure + }.getOrThrow() + }, + ) { result -> + onProgress(null) + pickerTarget = null + result.fold( + onSuccess = { summary -> + Feedback.show( + activity, + activity.getString( + R.string.status_platoon_csv_imported_with_checkpoint, + summary.retained, + summary.imported.imported, + summary.imported.historical, + summary.duplicates + summary.imported.skipped, + ), + long = true, + ) + }, + onFailure = { Feedback.show(activity, R.string.status_platoon_csv_import_failed, long = true) }, + ) + } + } + + private data class Target(val scope: PlatoonStorageScope, val profile: PlatoonProfile) + + private data class PendingImport( + val target: Target, + val prepared: List, + val duplicateFileNames: Set, + val preview: CsvImportPreviewAnalyzer.Preview, + ) + + private data class Summary( + val retained: Int, + val duplicates: Int, + val imported: PlatoonRepository.ImportResult, + ) + + private companion object { + const val MAX_FILES = 64 + const val MAX_BYTES = 16L * 1024 * 1024 + const val STATE_TARGET = "csv_import.target" + const val STATE_LAUNCH = "csv_import.launch_picker" + } +} diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index d3ef969..5d4ef18 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -1,114 +1,167 @@ package dev.gf2log.app import android.Manifest -import android.app.Activity -import android.app.AlertDialog import android.content.Intent import android.content.pm.PackageManager -import android.database.Cursor -import android.graphics.Color import android.graphics.Typeface -import android.graphics.drawable.ColorDrawable import android.graphics.drawable.GradientDrawable -import android.net.Uri import android.net.VpnService import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.provider.OpenableColumns -import android.view.View -import android.view.ViewGroup +import android.text.TextUtils import android.view.Gravity -import android.widget.Button -import android.widget.CheckBox -import android.widget.ImageButton +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView import android.widget.LinearLayout -import android.widget.PopupWindow -import android.widget.ScrollView +import android.widget.ProgressBar import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import dev.gf2log.app.capture.CaptureNotice +import dev.gf2log.app.capture.CapturePhase +import dev.gf2log.app.capture.CaptureSnapshot import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.capture.CaptureVpnService +import dev.gf2log.app.capture.format import dev.gf2log.app.history.CaptureHistoryStore import dev.gf2log.app.history.SavedHistoryStore -import dev.gf2log.app.management.PlatoonBackupManager -import dev.gf2log.app.management.CsvImportCheckpointManager -import dev.gf2log.app.management.CsvImportPreviewAnalyzer import dev.gf2log.app.management.PlatoonCsvImportStore -import dev.gf2log.app.management.PlatoonProfile -import dev.gf2log.app.management.PlatoonRepository -import dev.gf2log.app.management.PlatoonProfileRegistry -import dev.gf2log.app.management.PlatoonStorageScope -import dev.gf2log.app.management.BackupFileName -import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.AccessibleRow +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.BarAction +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.dpf +import dev.gf2log.app.ui.group +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.Gfl2PayloadDecoder -import dev.gf2log.protocol.PayloadCatalog import java.io.File -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.concurrent.Executors +/** + * Home: capture state and actions first, then the selected Platoon, data tools, and recent + * parsed-packet evidence. Capture state is observed through [CaptureStatus] listeners. + */ class MainActivity : BaseActivity() { + private lateinit var profileBinding: ActivePlatoonScopeBinding - private lateinit var statusText: TextView - private lateinit var captureStateText: TextView - private lateinit var captureStatusText: TextView - private lateinit var prepareCaptureButton: Button - private lateinit var captureOnceButton: Button - private lateinit var stopCaptureButton: Button - private lateinit var guidedCaptureText: TextView - private lateinit var historyContainer: LinearLayout - private lateinit var historyActions: LinearLayout - private lateinit var savedHistoryContainer: LinearLayout - private lateinit var savedHistoryActions: LinearLayout - private lateinit var historyStore: CaptureHistoryStore - private lateinit var savedHistoryStore: SavedHistoryStore - private val selectedHistoryIds = linkedSetOf() - private val selectedSavedHistoryIds = linkedSetOf() - private val statusHandler = Handler(Looper.getMainLooper()) - private val fileIoExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2FileIo") + private lateinit var screen: Screen + private val work = BackgroundWork(this, "GF2Home") + private lateinit var csvImport: CsvImportFlow + private var historyStore: CaptureHistoryStore? = null + private var savedStore: SavedHistoryStore? = null + + private lateinit var stateDot: View + private lateinit var stateTitle: TextView + private lateinit var stateDetail: TextView + private lateinit var checklist: LinearLayout + private lateinit var captureActions: LinearLayout + private lateinit var progressBanner: LinearLayout + private lateinit var progressText: TextView + private lateinit var historySection: LinearLayout + + private var captureOnceRequested = false + private var showingSaved = false + private var selecting = false + private val selectedIds = linkedSetOf() + private var showAll = false + private var recent = emptyList() + private var saved = emptyList() + private var lastPhase: CapturePhase? = null + + private val statusListener: (CaptureSnapshot) -> Unit = ::renderCapture + + private val vpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) startCaptureService() else CaptureStatus.markStopped() } - private val refreshStatus = object : Runnable { - override fun run() { - renderCaptureStatus() - renderGuidedCaptureProgress() - statusHandler.postDelayed(this, STATUS_REFRESH_MILLIS) + + private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + requestVpnAndStart() + } + + private val exportRoster = registerForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { uri -> + val scope = profileBinding.scope ?: return@registerForActivityResult + if (uri == null) 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)) + } + }, + ) { result -> + Feedback.show(this, if (result.isSuccess) R.string.roster_csv_exported else R.string.status_export_failed) } } - private var pendingExport: File? = null - private var captureOnceRequested = false - private var pendingCsvImport: PendingCsvImport? = null - private var pendingCsvPickerStorageId: String? = null + + private val exitSelection = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() = setSelecting(false) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - pendingCsvPickerStorageId = savedInstanceState?.getString(STATE_CSV_TARGET) - profileBinding = ActivePlatoonScopeBinding(this) if (!OnboardingPreferences.isCompleted(this)) { startActivity(Intent(this, OnboardingActivity::class.java)) finish() return } - val historyRoot = profileBinding.scope?.rootDirectory(this) - ?: File(cacheDir, "no-profile-history") - historyStore = CaptureHistoryStore(File(historyRoot, CaptureHistoryStore.HISTORY_DIRECTORY)) - savedHistoryStore = SavedHistoryStore(File(historyRoot, SavedHistoryStore.SAVED_HISTORY_DIRECTORY)) - setContentView(buildContentView()) - requestNotificationPermissionIfNeeded() - intent.getStringExtra(EXTRA_LAUNCH_CSV_PICKER) - ?.takeIf { it == profileBinding.scope?.storageId } - ?.let { storageId -> - intent.removeExtra(EXTRA_LAUNCH_CSV_PICKER) - pendingCsvPickerStorageId = storageId - window.decorView.post(::selectPlatoonCsvFiles) - } - } - - override fun onSaveInstanceState(outState: Bundle) { - pendingCsvPickerStorageId?.let { outState.putString(STATE_CSV_TARGET, it) } - super.onSaveInstanceState(outState) + profileBinding = ActivePlatoonScopeBinding(this) + profileBinding.scope?.rootDirectory(this)?.let { root -> + historyStore = CaptureHistoryStore(File(root, CaptureHistoryStore.HISTORY_DIRECTORY)) + savedStore = SavedHistoryStore(File(root, SavedHistoryStore.SAVED_HISTORY_DIRECTORY)) + } + csvImport = CsvImportFlow(this, work, { profileBinding.scope }, ::showProgress) + csvImport.restoreState(savedInstanceState) + savedInstanceState?.let { + captureOnceRequested = it.getBoolean(STATE_CAPTURE_ONCE) + showingSaved = it.getBoolean(STATE_SHOWING_SAVED) + showAll = it.getBoolean(STATE_SHOW_ALL) + } + onBackPressedDispatcher.addCallback(this, exitSelection) + screen = Screen(this, PrimaryNavigation.Destination.HOME) + screen.topBar + .setLeading(ImageView(this).apply { + setImageResource(R.mipmap.ic_launcher) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + layoutParams = FrameLayout.LayoutParams(dp(36), dp(36)) + }) + .setTitle(getString(R.string.app_name)) + .setActions(listOf(BarAction(R.drawable.ic_settings, getString(R.string.open_options)) { + startActivity(Intent(this, OptionsActivity::class.java)) + })) + screen.setBody(Screen.scrollingBody(this, buildContent())) + } + + override fun onStart() { + super.onStart() + if (::stateTitle.isInitialized) CaptureStatus.addListener(statusListener) + } + + override fun onStop() { + CaptureStatus.removeListener(statusListener) + super.onStop() } override fun onScreenResumed() { @@ -116,1102 +169,490 @@ class MainActivity : BaseActivity() { recreate() return } - if (!::captureStatusText.isInitialized) return - renderCaptureStatus() - refreshHistory() - statusHandler.postDelayed(refreshStatus, STATUS_REFRESH_MILLIS) - } - - override fun onPause() { - statusHandler.removeCallbacks(refreshStatus) - super.onPause() - } - - override fun onDestroy() { - fileIoExecutor.shutdownNow() - super.onDestroy() + loadHistory() + csvImport.onScreenReady() } - @Deprecated("Uses the platform VPN consent activity without an AndroidX dependency") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - when { - requestCode == REQUEST_VPN && resultCode == RESULT_OK -> startCaptureService() - requestCode == REQUEST_EXPORT -> { - val source = pendingExport - pendingExport = null - val destination = data?.data - if (resultCode != RESULT_OK || destination == null || source == null) return - runFileOperation( - successMessage = { getString(R.string.status_exported, source.name) }, - failureMessage = { getString(R.string.status_export_failed) }, - ) { - val output = dev.gf2log.app.ui.DocumentAccess.openOutput( - contentResolver, - destination, - ) - ?: error("Document provider did not open an output stream") - output.writer(Charsets.UTF_8).use { writer -> - val snapshot = requireNotNull( - GuildMembersCsv.parse(source.readText(Charsets.UTF_8)), - ) { "Stored Platoon CSV is invalid" } - writer.write(GuildMembersCsv.formatForSpreadsheet(snapshot)) - } - } - } - requestCode == REQUEST_BACKUP_EXPORT -> { - val destination = data?.data - if (resultCode != RESULT_OK || destination == null) return - val scope = profileBinding.scope ?: return - runFileOperation( - successMessage = { getString(R.string.status_backup_exported) }, - failureMessage = { getString(R.string.status_backup_failed) }, - ) { - val output = dev.gf2log.app.ui.DocumentAccess.openOutput( - contentResolver, - destination, - ) ?: error("Document provider did not open an output stream") - output.use { PlatoonBackupManager(this, scope).export(it) } - } - } - requestCode == REQUEST_BACKUP_IMPORT -> { - val source = data?.data - if (resultCode != RESULT_OK || source == null) return - if (!hasBackupExtension(source)) { - statusText.text = getString(R.string.invalid_platoon_backup) - return - } - runFileOperation( - successMessage = { getString(R.string.status_backup_restored) }, - failureMessage = { getString(R.string.status_backup_failed) }, - onSuccess = { recreate() }, - ) { - val input = dev.gf2log.app.ui.DocumentAccess.openInput(contentResolver, source) - ?: error("Document provider did not open an input stream") - input.use { PlatoonBackupManager.restoreSelected(this, it, complete = false) } - } - } - requestCode == REQUEST_CSV_IMPORT -> { - if (resultCode != RESULT_OK) { - pendingCsvPickerStorageId = null - return - } - val sources = buildList { - data?.clipData?.let { clip -> - repeat(clip.itemCount) { index -> add(clip.getItemAt(index).uri) } - } - data?.data?.let(::add) - }.distinct() - if (sources.isNotEmpty()) { - val target = resolveCsvImportTarget(pendingCsvPickerStorageId) - if (target == null) { - pendingCsvPickerStorageId = null - statusText.text = getString(R.string.csv_import_target_changed) - } else { - preparePlatoonCsvSources(sources, target) - } - } else { - pendingCsvPickerStorageId = null - } - } - } - } - - private fun runFileOperation( - successMessage: () -> String, - failureMessage: () -> String, - onSuccess: () -> Unit = {}, - operation: () -> Unit, - ) { - fileIoExecutor.execute { - val succeeded = runCatching(operation).isSuccess - statusHandler.post { - if (!isFinishing && !isDestroyed) { - statusText.text = if (succeeded) successMessage() else failureMessage() - if (succeeded) onSuccess() - } - } - } + override fun onSaveInstanceState(outState: Bundle) { + if (::csvImport.isInitialized) csvImport.saveState(outState) + outState.putBoolean(STATE_CAPTURE_ONCE, captureOnceRequested) + outState.putBoolean(STATE_SHOWING_SAVED, showingSaved) + outState.putBoolean(STATE_SHOW_ALL, showAll) + super.onSaveInstanceState(outState) } - private fun buildContentView(): View { - val spacing = dp(16) - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(spacing, dp(10), spacing, dp(20)) - - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(android.widget.ImageView(context).apply { - setImageResource(R.mipmap.ic_launcher) - scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE - }, LinearLayout.LayoutParams(dp(40), dp(40)).apply { - marginEnd = dp(10) - }) - addView(TextView(context).apply { - text = getString(R.string.app_name) - textSize = 22f - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_settings) - contentDescription = getString(R.string.open_options) - useModernIconStyle() - setPadding(dp(10), dp(10), dp(10), dp(10)) - setOnClickListener { - startActivity(Intent(this@MainActivity, OptionsActivity::class.java)) - } - }, LinearLayout.LayoutParams(dp(48), dp(48))) - }, matchWidth()) - - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(10), dp(10), dp(10), dp(10)) - background = ModernUi.panelBackground(context).apply { - setStroke(dp(1), getColor(R.color.outline)) - } - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.TOP - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(View(context).apply { - background = GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(getColor(R.color.accent)) - } - }, LinearLayout.LayoutParams(dp(8), dp(8)).apply { - marginEnd = dp(8) - }) - addView(TextView(context).apply { - text = getString(R.string.capture_status_label) - textSize = 13f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - }) - }, matchWidth()) - captureStateText = TextView(context).apply { - textSize = 22f - setTextColor(getColor(R.color.success_text)) - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(4), 0, 0) - } - addView(captureStateText, matchWidth()) - captureStatusText = TextView(context).apply { - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - } - addView(captureStatusText, matchWidth()) - }, LinearLayout.LayoutParams(dp(124), ViewGroup.LayoutParams.WRAP_CONTENT).apply { - marginEnd = dp(8) - }) - addView( - PlatoonProfileSelector.controls( - this@MainActivity, - compact = true, - showManageButton = false, - ), - LinearLayout.LayoutParams( - 0, - ViewGroup.LayoutParams.WRAP_CONTENT, - 1f, - ).apply { topMargin = dp(18) }, - ) - }, matchWidth()) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.BOTTOM - addView(TextView(context).apply { - text = getString(R.string.capture_target) - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(4)) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_info_outline) - contentDescription = getString(R.string.target_package_info) - useModernIconStyle() - setOnClickListener(::showTargetPackageInfo) - }, LinearLayout.LayoutParams(dp(48), dp(48))) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - dp(48), - )) - addView(fixedTargetField(SupportedGamePackages.HAOPLAY), matchWidth()) - addView( - fixedTargetField(SupportedGamePackages.DARKWINTER), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(6) }, - ) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - prepareCaptureButton = Button(context).apply { - text = getString(R.string.prepare_capture) - useCaptureActionStyle() - allowCompactMultilineLabel() - setOnClickListener { requestVpnAndStart(captureOnce = false) } - } - addView(prepareCaptureButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { - marginEnd = dp(3) - }) - captureOnceButton = Button(context).apply { - text = getString(R.string.capture_one_roster) - usePrimaryActionStyle() - allowCompactMultilineLabel() - setOnClickListener { requestVpnAndStart(captureOnce = true) } - } - addView(captureOnceButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { - marginStart = dp(3) - marginEnd = dp(3) - }) - stopCaptureButton = Button(context).apply { - text = getString(R.string.stop_capture) - useDestructiveActionStyle() - setOnClickListener { stopCaptureService() } - } - addView(stopCaptureButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { - marginStart = dp(3) - }) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(4) }) - guidedCaptureText = TextView(context).apply { - visibility = View.GONE - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(4), 0, 0) - } - addView(guidedCaptureText, matchWidth()) - renderCaptureStatus() - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) - - statusText = TextView(context).apply { - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setPadding(dp(4), dp(6), dp(4), 0) - } - addView(statusText, matchWidth()) - - addView(sectionLabel(getString(R.string.data_tools), topPadding = dp(12)), matchWidth()) - listOf( - ModernUi.listRow(context, getString(R.string.import_platoon_csv), icon = R.drawable.ic_edit) { - showCsvImportSelector() + private fun buildContent(): LinearLayout = column { + progressText = text(null, TextRole.SECONDARY) { setTextColor(getColor(R.color.accent_text)) } + progressBanner = panel(emphasized = true, padding = 12) { + visibility = View.GONE + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + addView(row { + addView(ProgressBar(context).apply { + isIndeterminate = true + indeterminateTintList = android.content.res.ColorStateList.valueOf(getColor(R.color.accent)) + }, lp(20, 20, end = 12)) + addView(progressText, lp(width = 0, weight = 1f)) + }, lp()) + } + addView(progressBanner, lp(top = 4, bottom = 8)) + addView(capturePanel(), lp(top = 4)) + addView(sectionHeader(getString(R.string.active_platoon_section)), lp()) + addView(PlatoonProfileSelector.card(this@MainActivity), lp()) + addView(sectionHeader(getString(R.string.data_tools)), lp()) + addView(group(listOf( + navigationRow(getString(R.string.import_platoon_csv), getString(R.string.import_platoon_csv_detail), R.drawable.ic_upload) { + csvImport.start() + }, + navigationRow(getString(R.string.undo_last_csv_import), getString(R.string.undo_last_csv_import_detail), R.drawable.ic_undo) { + csvImport.undo() + }, + navigationRow( + getString(R.string.export_latest_platoon_csv), + getString(R.string.export_latest_platoon_csv_detail), + R.drawable.ic_download, + ) { exportLatestRoster() }, + navigationRow(getString(R.string.backup_and_restore), getString(R.string.backup_and_restore_detail), R.drawable.ic_storage) { + startActivity(Intent(this@MainActivity, OptionsActivity::class.java)) + }, + )), lp()) + historySection = column() + addView(historySection, lp()) + } + + // region Capture + + private fun capturePanel(): View { + stateDot = View(this).apply { + background = GradientDrawable().apply { shape = GradientDrawable.OVAL } + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + stateTitle = text(null, TextRole.HEADLINE) { accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE } + stateDetail = text(null, TextRole.SECONDARY) { maxLines = 4 } + checklist = column { visibility = View.GONE } + captureActions = row(Gravity.TOP) + return panel(outlined = true) { + addView(row { + addView(stateDot, lp(10, 10, end = 8)) + addView(text(getString(R.string.capture_status_label), TextRole.LABEL), lp(width = 0, weight = 1f)) + }, lp()) + addView(stateTitle, lp(top = 6)) + addView(stateDetail, lp(top = 2)) + addView(checklist, lp(top = 8)) + addView(clientRows(), lp(top = 12)) + addView(captureActions, lp(top = 12)) + } + } + + private fun clientRows(): View = column { + SupportedGamePackages.all.forEachIndexed { index, packageName -> + val installed = isInstalled(packageName) + val client = if (packageName == SupportedGamePackages.HAOPLAY) "HaoPlay" else "Darkwinter" + val status = getString(if (installed) R.string.client_installed else R.string.client_not_installed) + addView(row { + minimumHeight = dp(40) + isFocusable = true + contentDescription = "$client. $packageName. $status" + addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(text(client, TextRole.BODY), lp()) + addView(text(packageName, TextRole.CAPTION) { + typeface = Typeface.MONOSPACE + textSize = 11f + maxLines = 1 + ellipsize = TextUtils.TruncateAt.MIDDLE + }, lp()) + }, lp(width = 0, weight = 1f)) + addView(badge( + status, + if (installed) R.color.success_surface else R.color.surface_variant, + if (installed) R.color.success_text else R.color.text_secondary, + ).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(WRAP, WRAP, start = 8)) + }, lp(top = if (index == 0) 0 else 6)) + } + } + + private fun renderCapture(snapshot: CaptureSnapshot) { + val phase = snapshot.phase + val (title, color) = when (phase) { + CapturePhase.STOPPED -> R.string.capture_ready to R.color.text_primary + CapturePhase.STARTING -> R.string.status_preparing to R.color.accent_text + CapturePhase.RUNNING -> R.string.capture_running to R.color.success_text + } + stateTitle.text = getString(title) + stateTitle.setTextColor(getColor(color)) + (stateDot.background as GradientDrawable).setColor( + getColor( + when (phase) { + CapturePhase.STOPPED -> R.color.outline_strong + CapturePhase.STARTING -> R.color.accent + CapturePhase.RUNNING -> R.color.success_mark }, - ModernUi.listRow(context, getString(R.string.export_platoon_backup), icon = R.drawable.ic_save) { - if (requireActiveScope() != null) exportPlatoonBackup() + ), + ) + val notice = snapshot.notice + stateDetail.text = when { + notice != null && notice.notice != CaptureNotice.PREPARING -> notice.format(this) + phase == CapturePhase.STOPPED -> getString(R.string.capture_ready_detail) + else -> getString(R.string.capture_preparing_detail) + } + stateDetail.setTextColor( + getColor( + when (notice?.notice?.severity) { + CaptureNotice.Severity.ERROR -> R.color.destructive_action + CaptureNotice.Severity.WARNING -> R.color.warning_text + else -> R.color.text_secondary }, - ModernUi.listRow(context, getString(R.string.undo_last_csv_import), icon = R.drawable.ic_arrow_back) { - if (requireActiveScope() != null) confirmUndoLastCsvImport() + ), + ) + renderChecklist(snapshot.guided) + if (phase != lastPhase) { + lastPhase = phase + renderCaptureActions(phase) + } + } + + private fun renderChecklist(guided: CaptureStatus.GuidedProgress?) { + checklist.removeAllViews() + checklist.visibility = if (guided == null) View.GONE else View.VISIBLE + guided ?: return + 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()) + } + } + + private fun renderCaptureActions(phase: CapturePhase) { + captureActions.removeAllViews() + if (phase == CapturePhase.STOPPED) { + captureActions.addView( + button(getString(R.string.capture_one_roster), Controls.Role.PRIMARY, R.drawable.ic_bolt) { + beginCapture(once = true) }, - ModernUi.listRow(context, getString(R.string.import_platoon_backup), icon = R.drawable.ic_save) { - confirmImportPlatoonBackup() + lp(width = 0, weight = 1f, end = 4), + ) + captureActions.addView( + button(getString(R.string.prepare_capture), Controls.Role.CAPTURE, R.drawable.ic_play) { + beginCapture(once = false) }, - ).forEach { row -> - addView(row, matchWidth()) - } - addView(sectionLabel(getString(R.string.recent_packets, CaptureHistoryStore.MAX_ENTRIES)), matchWidth()) - historyContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - } - addView(historyContainer, matchWidth()) - historyActions = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView(Button(context).apply { - text = getString(R.string.delete_selected_history) - useDestructiveTextActionStyle() - setOnClickListener { deleteSelectedHistory() } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.save_selected_history) - useSecondaryActionStyle() - setOnClickListener { saveSelectedHistory() } - }, matchWidth()) - } - addView(historyActions, matchWidth()) - - addView(sectionLabel(getString(R.string.saved_packets, SavedHistoryStore.MAX_ENTRIES)), matchWidth()) - savedHistoryContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - } - addView(savedHistoryContainer, matchWidth()) - savedHistoryActions = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView(Button(context).apply { - text = getString(R.string.delete_selected_saved_history) - useDestructiveTextActionStyle() - setOnClickListener { deleteSelectedSavedHistory() } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.export_latest_platoon_csv) - useSecondaryActionStyle() - setOnClickListener { exportLatestPlatoonCsv() } - }, matchWidth()) - } - addView(savedHistoryActions, matchWidth()) - } - val scroll = ScrollView(this).apply { addView(container, matchWidth()) } - return PrimaryNavigation.wrap(this, scroll, PrimaryNavigation.Destination.HOME) - } - - private fun sectionLabel(textValue: CharSequence, topPadding: Int = dp(18)) = TextView(this).apply { - text = textValue - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(dp(2), topPadding, 0, dp(7)) - } - - private fun renderCaptureStatus() { - if (!::captureStateText.isInitialized || !::captureStatusText.isInitialized) return - val starting = CaptureStatus.isStarting - val running = CaptureStatus.isRunning - captureStateText.text = getString( - when { - starting -> R.string.status_preparing - running -> R.string.capture_running - else -> R.string.capture_ready - }, - ) - val status = CaptureStatus.read() - val detail = when { - status.startsWith("Capturing only ") -> "" - status == "Capture is stopped" -> getString(R.string.capture_stopped_detail) - status == "Preparing capture" -> getString(R.string.status_preparing) - else -> status - } - captureStatusText.text = detail - captureStatusText.visibility = if (detail.isBlank()) View.GONE else View.VISIBLE - captureStateText.contentDescription = captureStateText.text - captureStatusText.contentDescription = captureStatusText.text - val busy = starting || running - if (::prepareCaptureButton.isInitialized) prepareCaptureButton.isEnabled = !busy - if (::captureOnceButton.isInitialized) captureOnceButton.isEnabled = !busy - if (::stopCaptureButton.isInitialized) stopCaptureButton.isEnabled = busy - } - - // Function Name: renderGuidedCaptureProgress - // Description: - // - Renders the four required Platoon payloads as a live checklist. - // - Keeps display logic independent from capture-service status messages. - // Parameters: - // - None. - // Returns: - // - Unit after updating the checklist visibility and text. - private fun renderGuidedCaptureProgress() { - val progress = CaptureStatus.readGuidedProgress() - guidedCaptureText.visibility = if (progress == null) View.GONE else View.VISIBLE - if (progress == null) return - fun mark(payloadType: Int): String = - if (payloadType in progress.capturedPayloadTypes) "\u2713" else "\u25cb" - guidedCaptureText.text = getString( - R.string.guided_capture_checklist, - mark(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE), - mark(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS), - mark(Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY), - mark(Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES), - ) - } - - private fun showTargetPackageInfo(anchor: View) { - val content = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(16), dp(14), dp(16), dp(16)) - background = ModernUi.panelBackground(context).apply { - setStroke(dp(1), getColor(R.color.outline_strong)) - } - addView(TextView(context).apply { - text = getString(R.string.target_package_info_title) - textSize = 16f - setTextColor(getColor(R.color.text_primary)) - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.target_package_info_message) - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setLineSpacing(0f, 1.15f) - setPadding(0, dp(8), 0, 0) - }, matchWidth()) - } - val popupWidth = minOf(dp(360), resources.displayMetrics.widthPixels - dp(32)) - PopupWindow( - content, - popupWidth, - ViewGroup.LayoutParams.WRAP_CONTENT, - true, - ).apply { - setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - isOutsideTouchable = true - elevation = dp(6).toFloat() - showAsDropDown(anchor, anchor.width - popupWidth, dp(4)) + lp(width = 0, weight = 1f, start = 4), + ) + } else { + captureActions.addView( + button(getString(R.string.stop_capture), Controls.Role.DESTRUCTIVE, R.drawable.ic_stop) { + stopCaptureService() + }, + lp(), + ) } } - private fun fixedTargetField(packageId: String) = TextView(this).apply { - text = packageId - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(12), 0, dp(12), 0) - minHeight = dp(38) - isClickable = false - isFocusable = false - background = ModernUi.panelBackground(context).apply { - setStroke(dp(1), getColor(R.color.outline)) + private fun beginCapture(once: Boolean) { + captureOnceRequested = once + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + // Capture works without it, but the foreground notification is the only stop control + // outside the app, so ask at the moment it becomes relevant. + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + requestVpnAndStart() } } - @Suppress("DEPRECATION") - private fun requestVpnAndStart(captureOnce: Boolean) { - captureOnceRequested = captureOnce - val permissionIntent = VpnService.prepare(this) - if (permissionIntent == null) { - startCaptureService() - } else { - startActivityForResult(permissionIntent, REQUEST_VPN) - } + private fun requestVpnAndStart() { + val consent = VpnService.prepare(this) + if (consent == null) startCaptureService() else vpnConsent.launch(consent) } private fun startCaptureService() { CaptureStatus.markStarting() - renderCaptureStatus() - val intent = Intent(this, CaptureVpnService::class.java) - .setAction(CaptureVpnService.ACTION_START) - .putExtra(CaptureVpnService.EXTRA_CAPTURE_ONCE, captureOnceRequested) - startForegroundService(intent) + startForegroundService( + Intent(this, CaptureVpnService::class.java) + .setAction(CaptureVpnService.ACTION_START) + .putExtra(CaptureVpnService.EXTRA_CAPTURE_ONCE, captureOnceRequested), + ) captureOnceRequested = false } private fun stopCaptureService() { - val intent = Intent(this, CaptureVpnService::class.java) - .setAction(CaptureVpnService.ACTION_STOP) - startService(intent) + startService(Intent(this, CaptureVpnService::class.java).setAction(CaptureVpnService.ACTION_STOP)) CaptureStatus.markStopped() - renderCaptureStatus() } - private fun requestNotificationPermissionIfNeeded() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED - ) { - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_NOTIFICATIONS) + private fun isInstalled(packageName: String): Boolean = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, 0) } - } + }.isSuccess - private fun requireActiveScope(): PlatoonStorageScope? = profileBinding.scope.also { scope -> - if (scope == null) { - showNoPlatoonMessage() - } - } + // endregion - private fun showNoPlatoonMessage() { - TransientMessage.show(this, R.string.no_platoon_detected_detail, android.widget.Toast.LENGTH_LONG) - } + // region Data tools - private fun showCsvImportSelector() { - if (CaptureStatus.isRunning) { - statusText.text = getString(R.string.stop_capture_before_csv_import) - return - } - PlatoonCsvImportPrompt.show(this, ::beginCsvImport) + private fun showProgress(message: CharSequence?) { + progressBanner.visibility = if (message == null) View.GONE else View.VISIBLE + progressText.text = message } - private fun beginCsvImport(profile: PlatoonProfile) { - val registry = PlatoonProfileRegistry(this) - if (!registry.setActive(profile.storageId)) { - statusText.text = getString(R.string.csv_import_target_changed) + private fun exportLatestRoster() { + val scope = profileBinding.scope ?: run { + Feedback.show(this, R.string.no_platoon_detected_detail, long = true) return } - pendingCsvPickerStorageId = profile.storageId - if (profileBinding.scope?.storageId == profile.storageId) { - selectPlatoonCsvFiles() - } else { - intent.putExtra(EXTRA_LAUNCH_CSV_PICKER, profile.storageId) - recreate() - } - } - - private fun resolveCsvImportTarget(storageId: String?): CsvImportTarget? { - val scope = storageId?.let(::PlatoonStorageScope) ?: return null - val registry = PlatoonProfileRegistry(this) - val activeProfile = registry.active() - if ( - activeProfile == null || - activeProfile.storageId != scope.storageId || - activeProfile.legacy || - profileBinding.scope != scope - ) { - statusText.text = getString(R.string.csv_import_target_changed) - return null + work.run(task = { PlatoonCsvImportStore.latestRetainedFile(scope.retainedCsvDirectory(this))?.name }) { result -> + val name = result.getOrNull() + if (name == null) Feedback.show(this, R.string.status_no_platoon_csv) else exportRoster.launch(name) } - return CsvImportTarget(scope, activeProfile) } - private fun openScopedActivity(activityClass: Class) { - if (requireActiveScope() != null) startActivity(Intent(this, activityClass)) - } + // endregion - @Suppress("DEPRECATION") - private fun exportPlatoonBackup() { - if (CaptureStatus.isRunning) { - statusText.text = getString(R.string.stop_capture_before_backup) - return - } - val title = "GF2logger-platoon-${BACKUP_TIME.format(LocalDateTime.now())}." + - PlatoonBackupManager.FILE_EXTENSION - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType(PlatoonBackupManager.MIME_TYPE) - .putExtra(Intent.EXTRA_TITLE, title) - startActivityForResult(intent, REQUEST_BACKUP_EXPORT) - } + // region Packet history - private fun confirmImportPlatoonBackup() { - if (CaptureStatus.isRunning) { - statusText.text = getString(R.string.stop_capture_before_backup) + private fun loadHistory() { + val recentStore = historyStore + val savedEntries = savedStore + if (recentStore == null || savedEntries == null) { + renderHistory() return } - AlertDialog.Builder(this) - .setTitle(R.string.import_platoon_backup) - .setMessage(R.string.import_backup_warning) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.import_platoon_backup) { _, _ -> importPlatoonBackup() } - .show() - } - - @Suppress("DEPRECATION") - // Function Name: selectPlatoonCsvFiles - // Description: - // - Opens Android's trusted document picker for one or more roster CSV files. - // Parameters: - // - None. - // Returns: - // - Unit after dispatching the picker activity. - private fun selectPlatoonCsvFiles() { - if (resolveCsvImportTarget(pendingCsvPickerStorageId) == null) { - pendingCsvPickerStorageId = null - return + work.run(key = HISTORY, task = { recentStore.list() to savedEntries.list() }) { result -> + result.getOrNull()?.let { (recentEntries, savedList) -> + recent = recentEntries + saved = savedList + val ids = (if (showingSaved) saved else recent).mapTo(mutableSetOf()) { it.id } + selectedIds.retainAll(ids) + } + renderHistory() } - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("text/csv") - .putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("text/csv", "text/comma-separated-values")) - .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) - startActivityForResult(intent, REQUEST_CSV_IMPORT) } - - // Function Name: preparePlatoonCsvSources - // Description: - // - Reconciles crash-left retained evidence before classifying selected source identities. - // - Validates a bounded selection without retaining any newly selected source. - // - Computes a user-visible impact preview from the recovered repository state. - // Parameters: - // - sources: Distinct document-provider URIs returned by the picker. - // Returns: - // - Unit after scheduling validation and preview display. - private fun preparePlatoonCsvSources(sources: List, target: CsvImportTarget) { - if (CaptureStatus.isRunning) { - statusText.text = getString(R.string.stop_capture_before_csv_import) - return - } - val storageScope = target.storageScope - statusText.text = getString(R.string.csv_import_preparing_preview) - fileIoExecutor.execute { - val result = runCatching { - // Recover an interrupted prior import before reading the preview baseline. - CsvImportCheckpointManager(this, storageScope) - require(sources.size <= MAX_CSV_IMPORT_FILES) { - "Too many Platoon CSV files were selected" - } - val directory = storageScope.retainedCsvDirectory(this) - val store = PlatoonCsvImportStore(directory) - val selected = ArrayList(sources.size) - var selectedBytes = 0L - sources.forEach { source -> - val input = dev.gf2log.app.ui.DocumentAccess.openInput(contentResolver, source) - ?: error("Document provider did not open an input stream") - val prepared = input.use(store::prepare) - selectedBytes += prepared.byteCount - require(selectedBytes <= MAX_CSV_IMPORT_BYTES) { - "Selected Platoon CSV files exceed the total import size limit" - } - selected += prepared - } - val unique = selected.distinctBy(PlatoonCsvImportStore.PreparedImport::fileName) - val repository = PlatoonRepository(this, storageScope) - repository.reconcileRetainedCsvFiles(directory) - val duplicateNames = CsvImportPreviewAnalyzer.duplicateFileNames( - prepared = unique, - representedSourceFiles = repository.representedSnapshotSources(), - ) - val analyzed = CsvImportPreviewAnalyzer.analyze( - prepared = unique, - duplicateFileNames = duplicateNames, - existingMembers = repository.listMemberStatuses(), - latestSnapshot = repository.listSnapshots(limit = 1).firstOrNull(), - ) - val preview = analyzed.copy( - validatedFiles = selected.size, - duplicateFiles = analyzed.duplicateFiles + selected.size - unique.size, - totalBytes = selectedBytes, - ) - PendingCsvImport( - storageScope = storageScope, - destinationProfile = target.profile, - prepared = unique, - duplicateFileNames = duplicateNames, - preview = preview, - ) - } - statusHandler.post { - if (isFinishing || isDestroyed) return@post - result.fold( - onSuccess = { - pendingCsvImport = it - showCsvImportPreview(it) - }, - onFailure = { - pendingCsvImport = null - pendingCsvPickerStorageId = null - statusText.text = getString(R.string.status_platoon_csv_import_failed) + private fun renderHistory() { + historySection.removeAllViews() + val entries = if (showingSaved) saved else recent + historySection.addView(row { + addView(sectionHeader(getString(R.string.packet_history_section)), lp(width = 0, weight = 1f)) + if (entries.isNotEmpty()) { + addView( + button(getString(if (selecting) R.string.done else R.string.select), Controls.Role.TERTIARY) { + setSelecting(!selecting) }, + lp(WRAP, WRAP, top = 8), ) } - } - } - - private fun showCsvImportPreview(pending: PendingCsvImport) { - val preview = pending.preview - statusText.text = "" - val range = if (preview.firstCapture == null || preview.lastCapture == null) { - getString(R.string.none) - } else { - getString( - R.string.csv_import_capture_range, - preview.firstCapture.toString(), - preview.lastCapture.toString(), - ) - } - AlertDialog.Builder(this) - .setTitle(R.string.csv_import_preview_title) - .setMessage( + }, lp()) + historySection.addView(Controls.segmentedControl( + context = this, + options = listOf( + RECENT to getString(R.string.recent_packets_tab, recent.size, CaptureHistoryStore.MAX_ENTRIES), + SAVED to getString(R.string.saved_packets_tab, saved.size, SavedHistoryStore.MAX_ENTRIES), + ), + selectedValue = if (showingSaved) SAVED else RECENT, + ) { value -> + showingSaved = value == SAVED + showAll = false + setSelecting(false) + }, lp()) + if (entries.isEmpty()) { + historySection.addView(text( getString( - R.string.csv_import_destination, - pending.destinationProfile.platoonName, - pending.destinationProfile.platoonId, - pending.destinationProfile.client.displayName, - csvImportRegionLabel(pending.destinationProfile.serverRegion), - ) + "\n\n" + getString( - R.string.csv_import_preview_summary, - preview.validatedFiles, - preview.duplicateFiles, - preview.historicalFiles, - preview.uniqueMembers, - preview.newMembers, - preview.nameDifferences, - preview.potentialJoins, - preview.potentialWithdrawals, - preview.totalBytes / 1024, - range, + when { + profileBinding.scope == null -> R.string.no_platoon_history + showingSaved -> R.string.no_saved_packets + else -> R.string.no_parsed_packets + }, ), - ) - .setNegativeButton(android.R.string.cancel) { _, _ -> - pendingCsvImport = null - pendingCsvPickerStorageId = null - } - .setPositiveButton(R.string.import_platoon_csv) { _, _ -> - applyPreparedCsvImport(pending) - } - .setOnCancelListener { - pendingCsvImport = null - pendingCsvPickerStorageId = null - } - .show() - } - - private fun csvImportRegionLabel(region: GameServerRegion): String = getString( - when (region) { - GameServerRegion.MANUAL -> R.string.server_region_manual - GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global - GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china - GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global - GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan - GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea - GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia - }, - ) - - private fun applyPreparedCsvImport(pending: PendingCsvImport) { - val registry = PlatoonProfileRegistry(this) - val targetStillExists = registry.find(pending.storageScope.storageId) - ?.let { - it.client == pending.destinationProfile.client && - it.platoonId == pending.destinationProfile.platoonId - } - ?: false - val targetStillSelected = registry.active()?.storageId == pending.storageScope.storageId - if ( - pendingCsvImport !== pending || - CaptureStatus.isRunning || - profileBinding.scope != pending.storageScope || - !targetStillExists || - !targetStillSelected - ) { - pendingCsvImport = null - pendingCsvPickerStorageId = null - statusText.text = getString( - if (CaptureStatus.isRunning) { - R.string.stop_capture_before_csv_import - } else { - R.string.csv_import_target_changed - }, - ) + TextRole.SECONDARY, + ) { setPadding(dp(4), dp(16), dp(4), dp(8)) }, lp()) return } - pendingCsvImport = null - statusText.text = getString(R.string.csv_import_applying) - fileIoExecutor.execute { - val directory = pending.storageScope.retainedCsvDirectory(this) - val store = PlatoonCsvImportStore(directory) - val checkpoint = CsvImportCheckpointManager(this, pending.storageScope) - var checkpointCreated = false - val result = runCatching { - val plannedNames = pending.prepared - .filterNot { it.fileName in pending.duplicateFileNames } - .mapTo(mutableSetOf()) { it.fileName } - checkpoint.create(plannedNames) - checkpointCreated = true - var retained = 0 - var duplicates = 0 - pending.prepared.forEach { prepared -> - store.retain(prepared).also { - if (it.duplicate) duplicates += 1 else retained += 1 - } + if (selecting) historySection.addView(selectionActions(), lp(top = 8)) + val visible = if (showAll) entries else entries.take(COLLAPSED_COUNT) + historySection.addView(panel(padding = 4) { + visible.forEachIndexed { index, entry -> + if (index > 0) addView(divider(insetStartDp = 12)) + addView(historyRow(entry), lp()) + } + }, lp(top = 8)) + if (!showAll && entries.size > COLLAPSED_COUNT) { + historySection.addView(button(getString(R.string.show_all_count, entries.size), Controls.Role.TERTIARY) { + showAll = true + renderHistory() + }, lp(top = 4)) + } + } + + private fun historyRow(entry: CaptureHistoryStore.Entry): View { + val isSelected = entry.id in selectedIds + val tag = Labels.payloadTag(this, entry.payloadType) + return AccessibleRow(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(52) + setPadding(dp(12), dp(4), dp(8), dp(4)) + background = selectableRect() + this.isSelected = selecting && isSelected + contentDescription = listOfNotNull( + getString(R.string.selected).takeIf { selecting && isSelected }, + entry.title, + getString( + R.string.payload_tag_description, + tag, + entry.payloadType?.toString() ?: getString(R.string.unknown_payload_type), + ), + ).joinToString(". ") + if (selecting) { + addView(icon( + if (isSelected) R.drawable.ic_check_circle else R.drawable.ic_circle_outline, + if (isSelected) R.color.accent else R.color.outline_strong, + sizeDp = 22, + ), lp(22, 22, end = 12)) + } + addView(text(entry.title, TextRole.BODY) { + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + }, lp(width = 0, weight = 1f)) + addView(text(tag, TextRole.LABEL) { + textSize = 11f + setTextColor(getColor(R.color.accent_text)) + setPadding(dp(8), dp(3), dp(8), dp(3)) + background = GradientDrawable().apply { + setColor(getColor(R.color.accent_surface)) + cornerRadius = dpf(8f) } - val imported = PlatoonRepository(this, pending.storageScope) - .reconcileRetainedCsvFiles(directory) - checkpoint.seal() - CsvImportSummary(retained, duplicates, imported) - }.recoverCatching { failure -> - if (checkpointCreated) { - runCatching(checkpoint::rollbackFailedImport).exceptionOrNull()?.let(failure::addSuppressed) + }, lp(WRAP, WRAP, start = 8)) + if (!selecting) addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) + setOnClickListener { + if (selecting) { + if (!selectedIds.add(entry.id)) selectedIds.remove(entry.id) + renderHistory() + } else { + openEntry(entry) } - throw failure } - statusHandler.post { - if (isFinishing || isDestroyed) return@post - pendingCsvPickerStorageId = null - statusText.text = result.fold( - onSuccess = { summary -> - getString( - R.string.status_platoon_csv_imported_with_checkpoint, - summary.retained, - summary.imported.imported, - summary.imported.historical, - summary.duplicates + summary.imported.skipped, - ) - }, - onFailure = { getString(R.string.status_platoon_csv_import_failed) }, - ) - } - } - } - - private fun confirmUndoLastCsvImport() { - if (CaptureStatus.isRunning) { - statusText.text = getString(R.string.stop_capture_before_csv_import) - return - } - val scope = requireActiveScope() ?: return - if (!CsvImportCheckpointManager(this, scope).canUndo()) { - TransientMessage.show(this, R.string.no_csv_import_checkpoint) - return - } - AlertDialog.Builder(this) - .setTitle(R.string.undo_last_csv_import) - .setMessage(R.string.undo_last_csv_import_warning) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.undo_last_csv_import) { _, _ -> undoLastCsvImport() } - .show() - } - - private fun undoLastCsvImport() { - val scope = requireActiveScope() ?: return - statusText.text = getString(R.string.csv_import_undoing) - fileIoExecutor.execute { - val succeeded = runCatching { CsvImportCheckpointManager(this, scope).restore() }.isSuccess - statusHandler.post { - if (isFinishing || isDestroyed) return@post - statusText.text = getString( - if (succeeded) R.string.csv_import_undone else R.string.csv_import_undo_failed, - ) + setOnLongClickListener { + if (!selecting) setSelecting(true) + selectedIds.add(entry.id) + renderHistory() + true } } } - @Suppress("DEPRECATION") - private fun importPlatoonBackup() { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("*/*") - .putExtra(Intent.EXTRA_MIME_TYPES, BACKUP_MIME_TYPES) - startActivityForResult(intent, REQUEST_BACKUP_IMPORT) - } - - private fun hasBackupExtension(uri: Uri): Boolean { - val name = contentResolver.query( - uri, - arrayOf(OpenableColumns.DISPLAY_NAME), - null, - null, - null, - )?.use { cursor: Cursor -> - if (!cursor.moveToFirst()) null else cursor.getString(0) - } ?: return false - return BackupFileName.isValid(name) - } - @Suppress("DEPRECATION") - private fun exportLatestPlatoonCsv() { - val directory = (requireActiveScope() ?: return).retainedCsvDirectory(this) - val latest = PlatoonCsvImportStore.latestRetainedFile(directory) - if (latest == null) { - statusText.text = getString(R.string.status_no_platoon_csv) - return - } - - pendingExport = latest - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("text/csv") - .putExtra(Intent.EXTRA_TITLE, latest.name) - startActivityForResult(intent, REQUEST_EXPORT) - } - - private fun refreshHistory() { - val recentEntries = historyStore.list() - renderHistoryEntries( - container = historyContainer, - entries = recentEntries, - selectedIds = selectedHistoryIds, - saved = false, - emptyMessage = R.string.no_parsed_packets, + private fun selectionActions(): View = row { + addView( + text(resources.getQuantityString(R.plurals.selected_count, selectedIds.size, selectedIds.size), TextRole.LABEL), + lp(width = 0, weight = 1f, start = 4), ) - historyActions.visibility = if (recentEntries.isEmpty()) View.GONE else View.VISIBLE - - val savedEntries = savedHistoryStore.list() - renderHistoryEntries( - container = savedHistoryContainer, - entries = savedEntries, - selectedIds = selectedSavedHistoryIds, - saved = true, - emptyMessage = R.string.no_saved_packets, + if (!showingSaved) { + addView(button(getString(R.string.save_selected_history_short), Controls.Role.SECONDARY, R.drawable.ic_save) { + saveSelected() + }.apply { isEnabled = selectedIds.isNotEmpty() }, lp(WRAP, WRAP, end = 8)) + } + addView(button(getString(R.string.delete), Controls.Role.DESTRUCTIVE_TEXT, R.drawable.ic_delete) { + confirmDeleteSelected() + }.apply { isEnabled = selectedIds.isNotEmpty() }, lp(WRAP, WRAP)) + } + + private fun setSelecting(enabled: Boolean) { + selecting = enabled + if (!enabled) selectedIds.clear() + exitSelection.isEnabled = enabled + renderHistory() + } + + private fun openEntry(entry: CaptureHistoryStore.Entry) { + startActivity( + Intent(this, PacketHistoryActivity::class.java) + .putExtra(PacketHistoryActivity.EXTRA_ENTRY_ID, entry.id) + .putExtra(PacketHistoryActivity.EXTRA_ENTRY_TITLE, entry.title) + .putExtra(PacketHistoryActivity.EXTRA_SAVED_ENTRY, showingSaved) + .putExtra(PacketHistoryActivity.EXTRA_STORAGE_ID, profileBinding.scope?.storageId), ) - savedHistoryActions.visibility = if (savedEntries.isEmpty()) View.GONE else View.VISIBLE } - private fun renderHistoryEntries( - container: LinearLayout, - entries: List, - selectedIds: MutableSet, - saved: Boolean, - emptyMessage: Int, - ) { - container.removeAllViews() - if (entries.isEmpty()) { - container.addView(TextView(this).apply { - text = getString(emptyMessage) - }, matchWidth()) - return - } - val rowHeight = dp(48) - val tagWidth = dp(112) - val tagHeight = dp(32) - entries.forEach { entry -> - container.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - minimumHeight = rowHeight - addView(CheckBox(context).apply { - isChecked = entry.id in selectedIds - contentDescription = getString(R.string.select_history_entry, entry.title) - setOnCheckedChangeListener { _, checked -> - if (checked) selectedIds += entry.id else selectedIds -= entry.id - } - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.MATCH_PARENT, - )) - addView(Button(context).apply { - text = entry.title - isAllCaps = false - useEvidenceActionStyle() - setOnClickListener { - startActivity( - Intent(this@MainActivity, PacketHistoryActivity::class.java) - .putExtra(PacketHistoryActivity.EXTRA_ENTRY_ID, entry.id) - .putExtra(PacketHistoryActivity.EXTRA_ENTRY_TITLE, entry.title) - .putExtra(PacketHistoryActivity.EXTRA_SAVED_ENTRY, saved) - .putExtra( - PacketHistoryActivity.EXTRA_STORAGE_ID, - profileBinding.scope?.storageId, - ), - ) - } - }, LinearLayout.LayoutParams(0, rowHeight, 1f)) - addView(TextView(context).apply { - val localizedTag = localizedPayloadTag(entry.payloadType) - text = localizedTag - textSize = 12f - setTextColor(getColor(R.color.primary_action_foreground)) - gravity = Gravity.CENTER - setPadding(dp(8), 0, dp(8), 0) - background = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = dp(10).toFloat() - setColor(getColor(R.color.primary_action_background)) - } - contentDescription = getString( - R.string.payload_tag_description, - localizedTag, - entry.payloadType?.toString() ?: getString(R.string.unknown_payload_type), + private fun saveSelected() { + val source = historyStore ?: return + val target = savedStore ?: return + val ids = selectedIds.toList() + work.run(task = { target.saveFrom(source, ids) }) { result -> + val saveResult = result.getOrNull() + Feedback.show( + this, + when { + saveResult == null -> getString(R.string.status_save_history_failed) + saveResult.saved > 0 && saveResult.limitReached -> resources.getQuantityString( + R.plurals.status_saved_history_at_limit, + saveResult.saved, + saveResult.saved, + SavedHistoryStore.MAX_ENTRIES, ) - }, LinearLayout.LayoutParams( - tagWidth, - tagHeight, - ).apply { marginStart = dp(8) }) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - } - } - - private fun localizedPayloadTag(payloadType: Int?): String = when (payloadType) { - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> getString(R.string.payload_tag_platoon) - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> getString(R.string.payload_tag_activity) - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> getString(R.string.payload_tag_updates) - Gfl2PayloadDecoder.TYPE_WEAPONS -> getString(R.string.payload_tag_weapons) - Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> getString(R.string.payload_tag_attachments) - Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> getString(R.string.payload_tag_common_keys) - Gfl2PayloadDecoder.TYPE_FORMATIONS -> getString(R.string.payload_tag_formations) - else -> PayloadCatalog.tag(payloadType) - } - - private fun deleteSelectedHistory() { - if (selectedHistoryIds.isEmpty()) { - statusText.text = getString(R.string.status_select_history_first) - return - } - val deleted = historyStore.delete(selectedHistoryIds) - selectedHistoryIds.clear() - statusText.text = resources.getQuantityString( - R.plurals.status_deleted_history, - deleted, - deleted, - ) - refreshHistory() - } - - private fun saveSelectedHistory() { - if (selectedHistoryIds.isEmpty()) { - statusText.text = getString(R.string.status_select_history_first) - return - } - val result = runCatching { - savedHistoryStore.saveFrom(historyStore, selectedHistoryIds) - }.getOrElse { - statusText.text = getString(R.string.status_save_history_failed) - return - } - selectedHistoryIds.clear() - statusText.text = when { - result.saved > 0 && result.limitReached -> resources.getQuantityString( - R.plurals.status_saved_history_at_limit, - result.saved, - result.saved, - SavedHistoryStore.MAX_ENTRIES, - ) - result.saved > 0 -> resources.getQuantityString( - R.plurals.status_saved_history, - result.saved, - result.saved, - ) - result.limitReached -> getString( - R.string.status_saved_history_limit, - SavedHistoryStore.MAX_ENTRIES, + saveResult.saved > 0 -> + resources.getQuantityString(R.plurals.status_saved_history, saveResult.saved, saveResult.saved) + saveResult.limitReached -> getString(R.string.status_saved_history_limit, SavedHistoryStore.MAX_ENTRIES) + else -> getString(R.string.status_history_already_saved) + }, ) - else -> getString(R.string.status_history_already_saved) + setSelecting(false) + loadHistory() } - refreshHistory() } - private fun deleteSelectedSavedHistory() { - if (selectedSavedHistoryIds.isEmpty()) { - statusText.text = getString(R.string.status_select_saved_history_first) - return + private fun confirmDeleteSelected() { + val ids = selectedIds.toList() + val fromSaved = showingSaved + Dialogs.confirm( + this, + getString(R.string.delete_history_title), + resources.getQuantityString(R.plurals.delete_history_message, ids.size, ids.size), + getString(R.string.delete), + destructive = true, + ) { + work.run(task = { (if (fromSaved) savedStore?.delete(ids) else historyStore?.delete(ids)) ?: 0 }) { result -> + val deleted = result.getOrDefault(0) + Feedback.show( + this, + resources.getQuantityString( + if (fromSaved) R.plurals.status_deleted_saved_history else R.plurals.status_deleted_history, + deleted, + deleted, + ), + ) + setSelecting(false) + loadHistory() + } } - val deleted = savedHistoryStore.delete(selectedSavedHistoryIds) - selectedSavedHistoryIds.clear() - statusText.text = resources.getQuantityString( - R.plurals.status_deleted_saved_history, - deleted, - deleted, - ) - refreshHistory() } - private fun matchWidth(): ViewGroup.LayoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + // endregion private companion object { - const val REQUEST_VPN = 100 - const val REQUEST_NOTIFICATIONS = 101 - const val REQUEST_EXPORT = 102 - const val REQUEST_BACKUP_EXPORT = 103 - const val REQUEST_BACKUP_IMPORT = 104 - const val REQUEST_CSV_IMPORT = 105 - const val MAX_CSV_IMPORT_FILES = 64 - const val MAX_CSV_IMPORT_BYTES = 16L * 1024 * 1024 - const val STATUS_REFRESH_MILLIS = 1_000L - const val EXTRA_LAUNCH_CSV_PICKER = "launch_csv_picker_for_storage_id" - const val STATE_CSV_TARGET = "pending_csv_target" - val BACKUP_TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") - val BACKUP_MIME_TYPES = arrayOf( - PlatoonBackupManager.MIME_TYPE, - "application/zip", - "application/octet-stream", - ) + const val HISTORY = "history" + const val RECENT = "recent" + const val SAVED = "saved" + const val COLLAPSED_COUNT = 20 + const val STATE_CAPTURE_ONCE = "home.capture_once" + const val STATE_SHOWING_SAVED = "home.showing_saved" + const val STATE_SHOW_ALL = "home.show_all" } - - private data class CsvImportSummary( - val retained: Int, - val duplicates: Int, - val imported: PlatoonRepository.ImportResult, - ) - - private data class PendingCsvImport( - val storageScope: PlatoonStorageScope, - val destinationProfile: PlatoonProfile, - val prepared: List, - val duplicateFileNames: Set, - val preview: CsvImportPreviewAnalyzer.Preview, - ) - - private data class CsvImportTarget( - val storageScope: PlatoonStorageScope, - val profile: PlatoonProfile, - ) } diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index f9b6bb5..05924a3 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -17,6 +17,7 @@ import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.management.isImmutableMembershipBoundary import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Feedback @@ -44,7 +45,6 @@ import java.time.ZoneId /** One member's profile, private note, and editable membership history. */ class MemberDetailActivity : BaseActivity() { - override val legacyStyling = false private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository @@ -101,7 +101,7 @@ class MemberDetailActivity : BaseActivity() { private fun buildContent() { nickname = textField(getString(R.string.member_nickname), id = R.id.member_nickname_input, maxLength = 256) note = textField(getString(R.string.member_private_note_hint), id = R.id.member_note_input, singleLine = false) - saveButton = button(getString(R.string.save_member), ModernUi.ControlRole.PRIMARY) { saveProfile() } + saveButton = button(getString(R.string.save_member), Controls.Role.PRIMARY) { saveProfile() } val watcher = object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = updateSaveState() @@ -121,7 +121,7 @@ class MemberDetailActivity : BaseActivity() { }, lp()) addView(row { addView(sectionHeader(getString(R.string.membership_history)), lp(width = 0, weight = 1f)) - addView(button(getString(R.string.add_membership_history_short), ModernUi.ControlRole.TERTIARY, R.drawable.ic_add_circle) { + addView(button(getString(R.string.add_membership_history_short), Controls.Role.TERTIARY, R.drawable.ic_add_circle) { addMembershipPeriod() }, lp(WRAP, WRAP, top = 8)) }, lp()) @@ -383,13 +383,13 @@ class MemberDetailActivity : BaseActivity() { private fun errorText() = text(null, TextRole.CAPTION) { setTextColor(getColor(R.color.destructive_action)) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE visibility = View.GONE } private fun android.widget.TextView.show(message: Int) { text = getString(message) visibility = View.VISIBLE - announceForAccessibility(text) } private fun boundaryText(date: LocalDate?, instant: Instant?, timeKnown: Boolean, zone: ZoneId): String { diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index 2bab2be..7e46ac7 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -24,6 +24,7 @@ import dev.gf2log.app.management.MemberStatus import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.settings.MemberOrderPreferences +import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Feedback @@ -49,7 +50,6 @@ import dev.gf2log.app.ui.text * dragging the handle or through the accessibility "move up/down" actions; every change saves. */ class MemberOrderActivity : BaseActivity() { - override val legacyStyling = false private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository @@ -108,7 +108,7 @@ class MemberOrderActivity : BaseActivity() { addView(fieldButton, lp(width = 0, height = 48, weight = 1.4f)) addView(directionButton, lp(width = 0, height = 48, weight = 1f, start = 8)) }, lp(top = 8)) - addView(button(getString(R.string.apply_sort), ModernUi.ControlRole.SECONDARY) { applySort() }, lp(top = 8)) + addView(button(getString(R.string.apply_sort), Controls.Role.SECONDARY) { applySort() }, lp(top = 8)) }, lp(top = 12, bottom = 8)) updateSortLabels() } diff --git a/app/src/main/java/dev/gf2log/app/ModernFeedback.kt b/app/src/main/java/dev/gf2log/app/ModernFeedback.kt deleted file mode 100644 index 774b10a..0000000 --- a/app/src/main/java/dev/gf2log/app/ModernFeedback.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.gf2log.app - -import android.app.AlertDialog -import android.content.Context -import android.widget.Toast -import androidx.annotation.StringRes - -/** Keeps app-owned dialogs visually consistent while preserving Android dialog behavior. */ -internal fun AlertDialog.applyModernDialogStyle(): AlertDialog = apply { - window?.setBackgroundDrawable(ModernUi.dialogBackground(context)) - getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(context.getColor(R.color.accent)) - getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(context.getColor(R.color.text_secondary)) - getButton(AlertDialog.BUTTON_NEUTRAL)?.setTextColor(context.getColor(R.color.text_secondary)) -} - -internal fun AlertDialog.Builder.showModern(): AlertDialog = show().applyModernDialogStyle() - -/** - * Replaces the currently visible transient message instead of extending a long Toast queue. - * Android does not expose its internal queue, so cancelling the prior app-owned Toast is the - * deterministic way to keep rapid repeated actions responsive. - */ -internal object TransientMessage { - private var active: Toast? = null - - @Synchronized - fun show(context: Context, message: CharSequence, duration: Int = Toast.LENGTH_SHORT) { - active?.cancel() - active = Toast.makeText(context.applicationContext, message, duration).also(Toast::show) - } - - fun show(context: Context, @StringRes message: Int, duration: Int = Toast.LENGTH_SHORT) { - show(context, context.getString(message), duration) - } -} diff --git a/app/src/main/java/dev/gf2log/app/ModernUi.kt b/app/src/main/java/dev/gf2log/app/ModernUi.kt deleted file mode 100644 index e795334..0000000 --- a/app/src/main/java/dev/gf2log/app/ModernUi.kt +++ /dev/null @@ -1,629 +0,0 @@ -package dev.gf2log.app - -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.Typeface -import android.graphics.drawable.Drawable -import android.graphics.drawable.GradientDrawable -import android.graphics.drawable.InsetDrawable -import android.graphics.drawable.RippleDrawable -import android.graphics.drawable.StateListDrawable -import android.text.TextUtils -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.CheckBox -import android.widget.EditText -import android.widget.FrameLayout -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.RadioButton -import android.widget.RadioGroup -import android.widget.ScrollView -import android.widget.TextView -import androidx.annotation.DrawableRes - -/** Shared presentation primitives for the warm, editorial product surface. */ -object ModernUi { - internal enum class ControlRole { - PRIMARY, - CAPTURE, - FEATURE, - EVIDENCE, - SECONDARY, - TERTIARY, - NAVIGATION, - SELECTOR, - DESTRUCTIVE, - DESTRUCTIVE_TEXT, - } - - fun prepareContent(root: View) { - root.setBackgroundColor(root.context.getColor(R.color.app_background)) - prepareScreenSurface(root) - applyControls(root) - } - - fun panelBackground(context: Context, emphasized: Boolean = false): GradientDrawable = - GradientDrawable().apply { - setColor(context.getColor(if (emphasized) R.color.accent_surface else R.color.surface)) - cornerRadius = context.dp(16).toFloat() - } - - /** Theme-aware surface used by app-owned dialogs without replacing native semantics. */ - fun dialogBackground(context: Context): GradientDrawable = GradientDrawable().apply { - setColor(context.getColor(R.color.surface)) - cornerRadius = context.dp(20).toFloat() - setStroke(context.dp(1), context.getColor(R.color.outline)) - } - - /** Flat list navigation used by dashboard utilities and grouped Settings rows. */ - fun listRow( - context: Context, - title: CharSequence, - detail: CharSequence? = null, - @DrawableRes icon: Int? = null, - onClick: () -> Unit, - ): View = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - addView(AccessibleActionRow(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - minimumHeight = context.dp(if (detail.isNullOrBlank()) 52 else 60) - setPadding(context.dp(8), context.dp(4), context.dp(4), context.dp(4)) - background = statefulSurface( - context = context, - normalColor = R.color.app_background, - pressedColor = R.color.surface_pressed, - disabledColor = R.color.app_background, - normalStroke = android.R.color.transparent, - pressedStroke = android.R.color.transparent, - radiusDp = 2, - ) - contentDescription = listOfNotNull(title, detail?.takeIf(CharSequence::isNotBlank)) - .joinToString(". ") - setOnClickListener { onClick() } - icon?.let { drawable -> - addView(ImageView(context).apply { - setImageResource(drawable) - imageTintList = ColorStateList.valueOf(context.getColor(R.color.text_secondary)) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(context.dp(24), context.dp(24)).apply { - marginEnd = context.dp(12) - }) - } - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - addView(TextView(context).apply { - text = title - textSize = 14f - setTextColor(context.getColor(R.color.text_primary)) - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - }, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - detail?.takeIf(CharSequence::isNotBlank)?.let { supportingText -> - addView(TextView(context).apply { - text = supportingText - textSize = 12f - setTextColor(context.getColor(R.color.text_secondary)) - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - }, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - } - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView(ImageView(context).apply { - setImageResource(R.drawable.ic_chevron_right) - imageTintList = ColorStateList.valueOf(context.getColor(R.color.text_secondary)) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(context.dp(22), context.dp(22)).apply { - marginStart = context.dp(8) - }) - }, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - addView(View(context).apply { - setBackgroundColor(context.getColor(R.color.outline)) - }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, context.dp(1)).apply { - marginStart = context.dp(if (icon == null) 8 else 44) - }) - } - - /** - * Creates a compact multi-line navigation row without presenting its copy as an oversized button. - * The complete row remains a native 56dp touch target and exposes button semantics. - */ - fun actionRow( - context: Context, - title: CharSequence, - detail: CharSequence? = null, - titleMaxLines: Int = 1, - onClick: () -> Unit, - ): View = AccessibleActionRow(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - minimumHeight = context.dp(56) - setPadding(context.dp(12), context.dp(8), context.dp(8), context.dp(8)) - background = statefulSurface( - context = context, - normalColor = R.color.surface, - pressedColor = R.color.surface_pressed, - disabledColor = R.color.surface_variant, - normalStroke = android.R.color.transparent, - pressedStroke = R.color.outline, - radiusDp = 14, - ) - contentDescription = listOfNotNull(title, detail?.takeIf(CharSequence::isNotBlank)) - .joinToString(". ") - setOnClickListener { onClick() } - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - addView(TextView(context).apply { - text = title - textSize = 15f - setTextColor(context.getColor(R.color.text_primary)) - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - maxLines = titleMaxLines - ellipsize = TextUtils.TruncateAt.END - }, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - detail?.takeIf(CharSequence::isNotBlank)?.let { supportingText -> - addView(TextView(context).apply { - text = supportingText - textSize = 13f - setTextColor(context.getColor(R.color.text_secondary)) - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - setPadding(0, context.dp(4), 0, 0) - }, ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) - } - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView(ImageView(context).apply { - setImageResource(R.drawable.ic_chevron_right) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(context.dp(28), context.dp(28)).apply { - marginStart = context.dp(8) - }) - } - - /** Compact mutually-exclusive choices used for language and theme settings. */ - fun segmentedControl( - context: Context, - options: List>, - selectedValue: String, - onSelected: (String) -> Unit, - ): View { - require(options.isNotEmpty()) { "A segmented control requires at least one option" } - val selectedTextColors = ColorStateList( - arrayOf( - intArrayOf(android.R.attr.state_checked), - intArrayOf(), - ), - intArrayOf( - context.getColor(R.color.accent_text), - context.getColor(R.color.text_primary), - ), - ) - val railInset = context.dp(4) - val segmentHeight = context.dp(40) - val selection = View(context).apply { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - background = GradientDrawable().apply { - setColor(context.getColor(R.color.accent_surface)) - cornerRadius = context.dp(10).toFloat() - } - } - val choices = RadioGroup(context).apply { - orientation = RadioGroup.HORIZONTAL - background = null - } - val rail = FrameLayout(context).apply { - minimumHeight = context.dp(48) - setPadding(railInset, railInset, railInset, railInset) - background = GradientDrawable().apply { - setColor(context.getColor(R.color.surface_variant)) - cornerRadius = context.dp(14).toFloat() - } - addView(selection, FrameLayout.LayoutParams(0, segmentHeight)) - addView( - choices, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - segmentHeight, - ), - ) - } - - var currentValue = selectedValue - fun moveSelection(index: Int, animated: Boolean, finished: (() -> Unit)? = null) { - val segment = choices.getChildAt(index) - if (segment == null || segment.width <= 0) { - rail.post { moveSelection(index, animated, finished) } - return - } - selection.layoutParams = (selection.layoutParams as FrameLayout.LayoutParams).apply { - width = segment.width - height = segmentHeight - } - val target = segment.left.toFloat() - selection.animate().cancel() - if (animated) { - choices.isEnabled = false - repeat(choices.childCount) { child -> choices.getChildAt(child).isEnabled = false } - selection.animate() - .translationX(target) - .setDuration(180L) - .withEndAction { - choices.isEnabled = true - repeat(choices.childCount) { child -> choices.getChildAt(child).isEnabled = true } - finished?.invoke() - } - .start() - } else { - selection.translationX = target - finished?.invoke() - } - } - - options.forEachIndexed { index, (value, label) -> - choices.addView(RadioButton(context).apply { - id = View.generateViewId() - text = label - textSize = 14f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - gravity = Gravity.CENTER - buttonDrawable = null - background = RippleDrawable( - ColorStateList.valueOf(context.getColor(R.color.accent_surface_pressed)), - null, - shape( - context, - android.R.color.white, - android.R.color.transparent, - 10, - ), - ) - setTextColor(selectedTextColors) - isChecked = value == selectedValue - minHeight = segmentHeight - minWidth = context.dp(48) - setPadding(0, 0, 0, 0) - setOnClickListener { - if (value == currentValue) return@setOnClickListener - currentValue = value - moveSelection(index, animated = true) { onSelected(value) } - } - }, RadioGroup.LayoutParams(0, segmentHeight, 1f)) - } - rail.addOnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> - if (right - left == oldRight - oldLeft) return@addOnLayoutChangeListener - val selectedIndex = options.indexOfFirst { it.first == currentValue }.coerceAtLeast(0) - moveSelection(selectedIndex, animated = false) - } - return rail - } - - internal fun styleButton(button: Button) { - val role = button.getTag(R.id.gf2_ui_role) as? ControlRole ?: ControlRole.SECONDARY - button.isAllCaps = false - button.elevation = 0f - button.stateListAnimator = null - button.minHeight = button.context.dp(48) - button.minWidth = button.context.dp(48) - val compactMultiline = button.getTag(R.id.gf2_ui_compact_multiline) == true - button.textSize = when { - compactMultiline -> 12f - role == ControlRole.FEATURE -> 13f - role == ControlRole.EVIDENCE -> 12f - else -> 14f - } - button.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - button.maxLines = if (role == ControlRole.FEATURE || compactMultiline) 2 else 1 - button.ellipsize = TextUtils.TruncateAt.END - button.gravity = if (role == ControlRole.NAVIGATION || role == ControlRole.FEATURE) { - Gravity.START or Gravity.CENTER_VERTICAL - } else { - Gravity.CENTER - } - button.setPaddingRelative(button.context.dp(12), 0, button.context.dp(12), 0) - - val colors = when (role) { - ControlRole.PRIMARY -> ButtonColors( - normal = R.color.primary_action_background, - pressed = R.color.primary_action_pressed, - disabled = R.color.surface_variant, - stroke = R.color.primary_action_background, - pressedStroke = R.color.primary_action_pressed, - text = R.color.primary_action_foreground, - insetVertical = 4, - radiusDp = 12, - ) - ControlRole.CAPTURE -> ButtonColors( - normal = R.color.success_surface, - pressed = R.color.success_pressed, - disabled = R.color.surface_variant, - stroke = R.color.success_text, - pressedStroke = R.color.success_text, - text = R.color.success_text, - insetVertical = 4, - radiusDp = 12, - ) - ControlRole.FEATURE -> ButtonColors( - normal = R.color.accent_surface, - pressed = R.color.accent_surface_pressed, - disabled = R.color.surface_variant, - stroke = android.R.color.transparent, - pressedStroke = R.color.accent, - text = R.color.accent_text, - insetVertical = 4, - radiusDp = 14, - ) - ControlRole.EVIDENCE -> ButtonColors( - normal = R.color.surface, - pressed = R.color.surface_pressed, - disabled = R.color.surface_variant, - stroke = android.R.color.transparent, - pressedStroke = R.color.outline_strong, - text = R.color.text_primary, - insetVertical = 8, - radiusDp = 10, - ) - ControlRole.DESTRUCTIVE -> ButtonColors( - normal = R.color.destructive_action_background, - pressed = R.color.destructive_action_pressed, - disabled = R.color.surface_variant, - stroke = R.color.destructive_action_background, - pressedStroke = R.color.destructive_action_pressed, - text = R.color.destructive_action_foreground, - insetVertical = 4, - radiusDp = 12, - ) - ControlRole.TERTIARY -> ButtonColors( - normal = R.color.app_background, - pressed = R.color.surface_pressed, - disabled = R.color.app_background, - stroke = android.R.color.transparent, - pressedStroke = android.R.color.transparent, - text = R.color.text_primary, - insetVertical = 5, - ) - ControlRole.DESTRUCTIVE_TEXT -> ButtonColors( - normal = R.color.app_background, - pressed = R.color.surface_pressed, - disabled = R.color.app_background, - stroke = android.R.color.transparent, - pressedStroke = android.R.color.transparent, - text = R.color.destructive_action, - insetVertical = 5, - ) - ControlRole.NAVIGATION -> ButtonColors( - normal = R.color.surface, - pressed = R.color.surface_pressed, - disabled = R.color.surface_variant, - stroke = android.R.color.transparent, - pressedStroke = R.color.outline_strong, - text = R.color.text_primary, - insetVertical = 4, - radiusDp = 12, - ) - ControlRole.SELECTOR -> ButtonColors( - normal = R.color.surface, - pressed = R.color.surface_pressed, - disabled = R.color.surface_variant, - stroke = R.color.outline_strong, - pressedStroke = R.color.accent, - text = R.color.text_primary, - insetVertical = 4, - radiusDp = 12, - ) - ControlRole.SECONDARY -> ButtonColors( - normal = R.color.surface, - pressed = R.color.surface_pressed, - disabled = R.color.surface_variant, - stroke = android.R.color.transparent, - pressedStroke = R.color.outline_strong, - text = R.color.text_primary, - insetVertical = 4, - radiusDp = 12, - ) - } - button.setTextColor(buttonTextColors(button.context, colors.text)) - button.backgroundTintList = null - button.background = InsetDrawable( - statefulSurface( - context = button.context, - normalColor = colors.normal, - pressedColor = colors.pressed, - disabledColor = colors.disabled, - normalStroke = colors.stroke, - pressedStroke = colors.pressedStroke, - radiusDp = colors.radiusDp, - ), - 0, - button.context.dp(colors.insetVertical), - 0, - button.context.dp(colors.insetVertical), - ) - // A restored Button can keep the drawable state from its previous platform - // background even though View.isEnabled is already true. Synchronize the new - // semantic drawable immediately after replacing it. - button.refreshDrawableState() - button.jumpDrawablesToCurrentState() - } - - private fun applyControls(view: View) { - when (view) { - is ScrollView -> view.isFillViewport = true - is CheckBox -> { - view.buttonTintList = controlTint(view.context) - view.minimumHeight = view.context.dp(48) - view.minimumWidth = view.context.dp(48) - } - is RadioButton -> { - view.buttonTintList = controlTint(view.context) - view.minimumHeight = view.context.dp(48) - view.minimumWidth = view.context.dp(48) - } - is Button -> styleButton(view) - is EditText -> { - view.elevation = 0f - view.typeface = Typeface.create("sans-serif", Typeface.NORMAL) - } - is ImageButton -> view.useModernIconStyle() - is TextView -> styleText(view) - } - if (view is ViewGroup) { - repeat(view.childCount) { index -> applyControls(view.getChildAt(index)) } - } - } - - private fun styleText(textView: TextView) { - if (textView.typeface == Typeface.MONOSPACE) return - val sizeSp = textView.textSize / - (textView.resources.displayMetrics.density * textView.resources.configuration.fontScale) - when { - sizeSp >= 24f && textView.typeface?.isBold == true -> { - textView.typeface = Typeface.create("sans-serif", Typeface.BOLD) - textView.letterSpacing = -0.01f - } - sizeSp >= 18f && textView.typeface?.isBold == true -> { - textView.typeface = Typeface.create("sans-serif-medium", Typeface.BOLD) - textView.letterSpacing = 0f - } - } - } - - private fun prepareScreenSurface(root: View) { - val content = root.findViewById(android.R.id.content) ?: return - val screen = content.getChildAt(0) ?: return - screen.setBackgroundColor(screen.context.getColor(R.color.app_background)) - if (screen is ScrollView) { - screen.isFillViewport = true - screen.clipToPadding = false - } - } - - private fun statefulSurface( - context: Context, - normalColor: Int, - pressedColor: Int, - disabledColor: Int, - normalStroke: Int, - pressedStroke: Int, - radiusDp: Int, - ): Drawable { - return StateListDrawable().apply { - addState( - intArrayOf(-android.R.attr.state_enabled), - shape(context, disabledColor, normalStroke, radiusDp), - ) - addState( - intArrayOf(android.R.attr.state_pressed), - shape(context, pressedColor, pressedStroke, radiusDp), - ) - addState(intArrayOf(), shape(context, normalColor, normalStroke, radiusDp)) - } - } - - private fun shape( - context: Context, - fillColor: Int, - strokeColor: Int, - radiusDp: Int, - ) = GradientDrawable().apply { - setColor(context.getColor(fillColor)) - cornerRadius = context.dp(radiusDp).toFloat() - if (strokeColor != android.R.color.transparent) { - setStroke(context.dp(1), context.getColor(strokeColor)) - } - } - - private fun buttonTextColors(context: Context, enabledColor: Int) = ColorStateList( - arrayOf( - intArrayOf(-android.R.attr.state_enabled), - intArrayOf(), - ), - intArrayOf( - context.getColor(R.color.text_secondary), - context.getColor(enabledColor), - ), - ) - - internal fun controlTint(context: Context) = ColorStateList( - arrayOf( - intArrayOf(-android.R.attr.state_enabled), - intArrayOf(android.R.attr.state_checked), - intArrayOf(), - ), - intArrayOf( - context.getColor(R.color.outline_strong), - context.getColor(R.color.accent), - context.getColor(R.color.outline_strong), - ), - ) - - internal fun switchThumbTint(context: Context) = ColorStateList( - arrayOf( - intArrayOf(-android.R.attr.state_enabled), - intArrayOf(android.R.attr.state_checked), - intArrayOf(), - ), - intArrayOf( - context.getColor(R.color.surface_variant), - context.getColor(R.color.accent), - context.getColor(R.color.surface), - ), - ) - - internal fun switchTrackTint(context: Context) = ColorStateList( - arrayOf( - intArrayOf(-android.R.attr.state_enabled), - intArrayOf(android.R.attr.state_checked), - intArrayOf(), - ), - intArrayOf( - context.getColor(R.color.outline), - context.getColor(R.color.accent_surface_pressed), - context.getColor(R.color.outline_strong), - ), - ) - - private data class ButtonColors( - val normal: Int, - val pressed: Int, - val disabled: Int, - val stroke: Int, - val pressedStroke: Int, - val text: Int, - val insetVertical: Int, - val radiusDp: Int = 2, - ) -} - -private class AccessibleActionRow(context: Context) : LinearLayout(context) { - override fun getAccessibilityClassName(): CharSequence = Button::class.java.name -} - -internal fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - -internal fun ImageButton.useModernIconStyle() { - background = InsetDrawable(ModernUi.panelBackground(context), context.dp(4)) - imageTintList = ColorStateList.valueOf(context.getColor(R.color.primary)) - elevation = 0f -} diff --git a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt index 441d69e..bd109c2 100644 --- a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt @@ -11,10 +11,14 @@ import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.Button +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.dp import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout -import android.widget.RadioButton import android.widget.ScrollView import android.widget.TextView import androidx.annotation.DrawableRes @@ -22,6 +26,7 @@ import androidx.annotation.StringRes /** One-time, bilingual walkthrough of the app's five user-facing workflows. */ class OnboardingActivity : BaseActivity() { + override fun supportsPendingPlatoonAdmission(): Boolean = false private lateinit var pageHost: FrameLayout @@ -39,7 +44,13 @@ class OnboardingActivity : BaseActivity() { pageIndex = savedInstanceState?.getInt(STATE_PAGE_INDEX) ?.coerceIn(PAGES.indices) ?: 0 - setContentView(buildContent()) + val root = buildContent() + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + view.setPadding(bars.left + dp(20), bars.top + dp(8), bars.right + dp(20), bars.bottom + dp(12)) + WindowInsetsCompat.CONSUMED + } + setContentView(root) renderPage(animate = false) } @@ -51,27 +62,29 @@ class OnboardingActivity : BaseActivity() { /** Builds the stable header, content host, and bottom navigation frame. */ private fun buildContent(): View = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL - setPadding(dp(20), dp(8), dp(20), dp(12)) + setBackgroundColor(getColor(R.color.app_background)) addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL addView( - OnboardingLanguageToggle(context, LanguagePreferences.get(context)) { language -> + Controls.segmentedControl( + context = context, + options = listOf( + LanguagePreferences.DEFAULT_LANGUAGE to "English", + LanguagePreferences.KOREAN to "한국어", + ), + selectedValue = LanguagePreferences.get(context), + ) { language -> if (LanguagePreferences.get(this@OnboardingActivity) != language) { LanguagePreferences.set(this@OnboardingActivity, language) recreate() } - }, - LinearLayout.LayoutParams(dp(120), dp(54)), + }.apply { contentDescription = getString(R.string.onboarding_language_toggle) }, + LinearLayout.LayoutParams(dp(176), ViewGroup.LayoutParams.WRAP_CONTENT), ) addView(View(context), LinearLayout.LayoutParams(0, dp(1), 1f)) - skipButton = Button(context).apply { - text = getString(R.string.onboarding_skip) - contentDescription = getString(R.string.onboarding_skip) - useTertiaryActionStyle() - setOnClickListener { finishOnboarding() } - } + skipButton = button(getString(R.string.onboarding_skip), Controls.Role.TERTIARY) { finishOnboarding() } addView(skipButton, LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, dp(48), @@ -87,28 +100,21 @@ class OnboardingActivity : BaseActivity() { addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL - backButton = Button(context).apply { - text = getString(R.string.onboarding_back) - useSecondaryActionStyle() - setOnClickListener { - if (pageIndex > 0) { - pageIndex-- - renderPage(animate = true, direction = -1) - } + backButton = button(getString(R.string.onboarding_back), Controls.Role.SECONDARY) { + if (pageIndex > 0) { + pageIndex-- + renderPage(animate = true, direction = -1) } } addView(backButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { marginEnd = dp(6) }) - nextButton = Button(context).apply { - usePrimaryActionStyle() - setOnClickListener { - if (pageIndex == PAGES.lastIndex) { - finishOnboarding() - } else { - pageIndex++ - renderPage(animate = true, direction = 1) - } + nextButton = button("", Controls.Role.PRIMARY) { + if (pageIndex == PAGES.lastIndex) { + finishOnboarding() + } else { + pageIndex++ + renderPage(animate = true, direction = 1) } } addView(nextButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { @@ -144,9 +150,7 @@ class OnboardingActivity : BaseActivity() { nextButton.text = getString( if (pageIndex == PAGES.lastIndex) R.string.onboarding_finish else R.string.onboarding_next, ) - nextButton.isEnabled = true - nextButton.usePrimaryActionStyle() - skipButton.visibility = View.VISIBLE + skipButton.visibility = if (pageIndex == PAGES.lastIndex) View.INVISIBLE else View.VISIBLE } /** Builds one scroll-safe page from localized product copy and authored icon roles. */ @@ -208,9 +212,9 @@ class OnboardingActivity : BaseActivity() { textSize = 13f setTextColor(getColor(R.color.warning_text)) setPadding(dp(12), dp(9), dp(12), dp(9)) - background = ModernUi.panelBackground(context, emphasized = true).apply { + background = GradientDrawable().apply { setColor(getColor(R.color.warning_surface)) - cornerRadius = dp(8).toFloat() + cornerRadius = dp(12).toFloat() } }, LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -256,8 +260,10 @@ class OnboardingActivity : BaseActivity() { } private fun finishOnboarding() { - OnboardingPreferences.complete(this) - startActivity(Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) + if (!intent.getBooleanExtra(EXTRA_REPLAY, false)) { + OnboardingPreferences.complete(this) + startActivity(Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) + } finish() } @@ -276,6 +282,8 @@ class OnboardingActivity : BaseActivity() { ) companion object { + /** Opens the guide from Settings without touching the first-launch completion flag. */ + const val EXTRA_REPLAY = "onboarding.replay" private const val STATE_PAGE_INDEX = "page_index" private val PAGES = listOf( OnboardingPage( @@ -337,84 +345,3 @@ class OnboardingActivity : BaseActivity() { ) } } - -/** Two-label segmented control whose selection thumb slides between English and Korean. */ -private class OnboardingLanguageToggle( - context: Context, - initialLanguage: String, - private val onLanguageChanged: (String) -> Unit, -) : FrameLayout(context) { - private val thumb = View(context) - private var selectedLanguage = initialLanguage - private val english = label("English", LanguagePreferences.DEFAULT_LANGUAGE) - private val korean = label("한국어", LanguagePreferences.KOREAN) - - init { - setPadding(dp(3), dp(3), dp(3), dp(3)) - background = ModernUi.panelBackground(context).apply { cornerRadius = dp(12).toFloat() } - thumb.background = ModernUi.panelBackground(context, emphasized = true).apply { - cornerRadius = dp(9).toFloat() - } - addView(thumb) - addView(english) - addView(korean) - post { select(selectedLanguage, false) } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val width = resolveSize(dp(120), widthMeasureSpec) - val height = resolveSize(dp(54), heightMeasureSpec) - setMeasuredDimension(width, height) - val childWidth = (width - paddingLeft - paddingRight) / 2 - val childHeight = height - paddingTop - paddingBottom - val widthSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY) - val heightSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY) - repeat(childCount) { index -> getChildAt(index).measure(widthSpec, heightSpec) } - } - - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - val width = right - left - paddingLeft - paddingRight - val height = bottom - top - paddingTop - paddingBottom - val half = width / 2 - thumb.layout(paddingLeft, paddingTop, paddingLeft + half, paddingTop + height) - english.layout(paddingLeft, paddingTop, paddingLeft + half, paddingTop + height) - korean.layout(paddingLeft + half, paddingTop, paddingLeft + width, paddingTop + height) - thumb.translationX = if (selectedLanguage == LanguagePreferences.KOREAN) half.toFloat() else 0f - } - - private fun label(textValue: String, language: String) = RadioButton(context).apply { - text = textValue - buttonDrawable = null - gravity = Gravity.CENTER - textSize = 12f - setTypeface(typeface, Typeface.BOLD) - minimumHeight = dp(48) - isChecked = selectedLanguage == language - setOnClickListener { select(language, true) } - } - - private fun select(language: String, animate: Boolean) { - selectedLanguage = language - val distance = ((width - paddingLeft - paddingRight) / 2).toFloat() - val target = if (language == LanguagePreferences.KOREAN) distance else 0f - english.isChecked = language == LanguagePreferences.DEFAULT_LANGUAGE - korean.isChecked = language == LanguagePreferences.KOREAN - if (animate && ValueAnimator.areAnimatorsEnabled()) { - thumb.animate().cancel() - thumb.animate() - .translationX(target) - .setDuration(140L) - .withEndAction { onLanguageChanged(language) } - .start() - } else { - thumb.translationX = target - if (animate) onLanguageChanged(language) - } - val selectedColor = context.getColor(R.color.accent_text) - val idleColor = context.getColor(R.color.text_primary) - english.setTextColor(if (language == LanguagePreferences.DEFAULT_LANGUAGE) selectedColor else idleColor) - korean.setTextColor(if (language == LanguagePreferences.KOREAN) selectedColor else idleColor) - } - - private fun dp(value: Int): Int = context.dp(value) -} diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 1640306..7ce6a8e 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -1,754 +1,482 @@ package dev.gf2log.app -import android.app.Activity import android.app.AlertDialog +import android.content.ClipData +import android.content.ClipboardManager import android.content.Intent -import android.content.res.ColorStateList -import android.database.Cursor -import android.graphics.Typeface +import android.content.pm.PackageManager import android.net.Uri -import android.graphics.drawable.GradientDrawable +import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.provider.OpenableColumns import android.text.InputType import android.view.View -import android.view.ViewGroup -import android.view.Gravity -import android.widget.Button -import android.widget.CheckBox -import android.widget.EditText -import android.widget.ImageButton import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import android.widget.Toast -import dev.gf2log.app.settings.PayloadHistoryPreferences -import dev.gf2log.app.settings.CapturePreferences -import dev.gf2log.app.settings.GameTimeZonePreferences -import dev.gf2log.app.settings.ClientServerRegionPreferences -import dev.gf2log.app.settings.GameServerRegion +import androidx.activity.result.contract.ActivityResultContracts import dev.gf2log.app.capture.CaptureDiagnosticsStore import dev.gf2log.app.capture.CaptureStatus +import dev.gf2log.app.discord.DiscordWebhookSecretStore import dev.gf2log.app.management.BackupFileName import dev.gf2log.app.management.InvalidBackupException import dev.gf2log.app.management.PlatoonBackupManager -import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope -import dev.gf2log.app.discord.DiscordWebhookSecretStore -import dev.gf2log.protocol.Gfl2PayloadDecoder -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.LocalDateTime -import java.util.concurrent.Executors +import dev.gf2log.app.settings.CapturePreferences +import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.settings.PayloadHistoryPreferences +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.group +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.switchRow +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField import dev.gf2log.protocol.PayloadCatalog +import java.time.LocalDateTime +import java.time.ZoneId +/** Settings: grouped rows for the Platoon, display, capture, backup, sharing, and about. */ class OptionsActivity : BaseActivity() { + private lateinit var profileBinding: ActivePlatoonScopeBinding - private val mainHandler = Handler(Looper.getMainLooper()) - private val fileIoExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2FullBackup") + private val work = BackgroundWork(this, "GF2Settings") + private var pendingExportComplete = false + + private val exportBackup = registerForActivityResult( + ActivityResultContracts.CreateDocument(PlatoonBackupManager.MIME_TYPE), + ) { uri -> + val scope = profileBinding.scope + if (uri == null || scope == null) return@registerForActivityResult + val complete = pendingExportComplete + runBackup( + progress = R.string.backup_exporting, + success = if (complete) R.string.full_backup_exported else R.string.status_backup_exported, + failure = { R.string.full_backup_export_failed }, + reload = false, + ) { + DocumentAccess.openOutput(contentResolver, uri).use { output -> + val manager = PlatoonBackupManager(this, scope) + if (complete) manager.exportFull(output) else manager.export(output) + } + } + } + + private val restoreBackup = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let(::restore) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) profileBinding = ActivePlatoonScopeBinding(this) - title = getString(R.string.payload_options) - setContentView(buildContentView()) + pendingExportComplete = savedInstanceState?.getBoolean(STATE_EXPORT_COMPLETE) == true + val screen = Screen(this) + screen.topBar.setNavigation(::finish).setTitle(getString(R.string.payload_options)) + screen.setBody(Screen.scrollingBody(this, buildContent())) } override fun onScreenResumed() { - if (!profileBinding.isCurrent(this)) { - recreate() - return - } + if (!profileBinding.isCurrent(this)) recreate() } - override fun onDestroy() { - fileIoExecutor.shutdownNow() - super.onDestroy() - } - - @Deprecated("Uses the platform document picker without an AndroidX dependency") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - val uri = data?.data - if (resultCode != Activity.RESULT_OK || uri == null) return - when (requestCode) { - REQUEST_FULL_BACKUP_EXPORT -> { - val scope = requireActiveScope() ?: return - runBackupOperation( - successMessage = R.string.full_backup_exported, - failureMessage = { R.string.full_backup_export_failed }, - ) { - val output = dev.gf2log.app.ui.DocumentAccess.openOutput(contentResolver, uri) - ?: error("Document provider did not open an output stream") - output.use { PlatoonBackupManager(this, scope).exportFull(it) } - } - } - REQUEST_FULL_BACKUP_RESTORE -> { - if (!hasBackupExtension(uri)) { - showBackupMessage(R.string.invalid_full_backup) - return - } - runBackupOperation( - successMessage = R.string.full_backup_restored, - failureMessage = ::fullBackupRestoreFailureMessage, - ) { - val input = dev.gf2log.app.ui.DocumentAccess.openInput(contentResolver, uri) - ?: error("Document provider did not open an input stream") - input.use { PlatoonBackupManager.restoreSelected(this, it, complete = true) } - } - } - } + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(STATE_EXPORT_COMPLETE, pendingExportComplete) + super.onSaveInstanceState(outState) } - private fun buildContentView(): ScrollView { - val spacing = dp(16) - val preferences = PayloadHistoryPreferences(this) - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(spacing, dp(8), spacing, spacing) - - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(ImageButton(context).apply { - setImageResource(R.drawable.ic_arrow_back) - contentDescription = getString(R.string.back) - useModernIconStyle() - setPadding(dp(11), dp(11), dp(11), dp(11)) - setOnClickListener { finish() } - }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(4) }) - addView(TextView(context).apply { - text = getString(R.string.payload_options) - textSize = 22f - setTypeface(typeface, Typeface.BOLD) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - }, matchWidth()) - addView( - PlatoonProfileSelector.controls(this@OptionsActivity), - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(6) }, - ) - - addView(TextView(context).apply { - text = getString(R.string.language) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(10), 0, dp(2)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.settings_language_detail) - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(6)) - }, matchWidth()) - addView(ModernUi.segmentedControl( - context = context, + private fun buildContent(): LinearLayout = column { + val scope = profileBinding.scope + addView(sectionHeader(getString(R.string.settings_platoon)), lp()) + addView(PlatoonProfileSelector.card(this@OptionsActivity), lp()) + addView(group(listOf( + navigationRow( + getString(R.string.manage_platoon_profiles), + getString(R.string.manage_platoon_profiles_detail), + R.drawable.ic_group, + ) { PlatoonProfileSelector.showManagement(this@OptionsActivity) }, + navigationRow( + getString(R.string.server_region), + scope?.let { resetSummary(it.storageId) } ?: getString(R.string.no_platoon_detected), + R.drawable.ic_calendar, + enabled = scope != null, + ) { scope?.let(::chooseResetRegion) }, + )), lp(top = 8)) + + addView(sectionHeader(getString(R.string.settings_display)), lp()) + addView(panel(padding = 12) { + addView(text(getString(R.string.language), TextRole.LABEL), lp()) + addView(Controls.segmentedControl( + context = this@OptionsActivity, options = listOf( LanguagePreferences.KOREAN to getString(R.string.language_korean_native), LanguagePreferences.DEFAULT_LANGUAGE to getString(R.string.language_english), ), - selectedValue = LanguagePreferences.get(context), - onSelected = ::changeLanguage, - ), matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.appearance) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, dp(2)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.settings_appearance_detail) - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(6)) - }, matchWidth()) - addView(ModernUi.segmentedControl( - context = context, + selectedValue = LanguagePreferences.get(this@OptionsActivity), + ) { language -> + if (LanguagePreferences.get(this@OptionsActivity) != language) { + LanguagePreferences.set(this@OptionsActivity, language) + recreate() + } + }, lp(top = 6)) + addView(text(getString(R.string.appearance), TextRole.LABEL), lp(top = 16)) + addView(Controls.segmentedControl( + context = this@OptionsActivity, options = listOf( ThemePreferences.SYSTEM to getString(R.string.theme_system), ThemePreferences.LIGHT to getString(R.string.theme_light), ThemePreferences.DARK to getString(R.string.theme_dark), ), - selectedValue = ThemePreferences.get(context), - onSelected = ::changeTheme, - ), matchWidth()) - - addView(TextView(context).apply { - text = getString(R.string.daily_reset_time) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, dp(2)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.settings_daily_reset_detail) - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(6)) - }, matchWidth()) - val resetScope = profileBinding.scope - val resetRegion = resetScope?.let { GameTimeZonePreferences.region(context, it.storageId) } - addView(ModernUi.listRow( - context = context, - title = getString(R.string.server_region), - detail = if (resetRegion == null) { - getString(R.string.no_platoon_detected) - } else { - resetSummary( - resetRegion, - GameTimeZonePreferences.isAutomatic(context, resetScope.storageId), - ) - }, - icon = R.drawable.ic_calendar, - onClick = { - if (resetScope == null) { - TransientMessage.show(context, R.string.no_platoon_detected_detail) - } else { - chooseGameServerRegion() - } - }, - ), matchWidth()) - addView(ModernUi.listRow( - context = context, - title = getString(R.string.haoplay_capture_region), - detail = clientRegionLabel(SupportedGamePackages.HAOPLAY), - icon = R.drawable.ic_group, - onClick = { chooseClientServerRegion(SupportedGamePackages.HAOPLAY) }, - ), matchWidth()) - addView(ModernUi.listRow( - context = context, - title = getString(R.string.darkwinter_capture_region), - detail = clientRegionLabel(SupportedGamePackages.DARKWINTER), - icon = R.drawable.ic_group, - onClick = { chooseClientServerRegion(SupportedGamePackages.DARKWINTER) }, - ), matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.backup) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, dp(2)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.settings_backup_detail) - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(3)) - }, matchWidth()) - addView(ModernUi.listRow( - context, - getString(R.string.restore_full_backup), - getString(R.string.restore_full_backup_description), - R.drawable.ic_arrow_back, - ::confirmFullRestore, - ), matchWidth()) - addView(ModernUi.listRow( - context, + selectedValue = ThemePreferences.get(this@OptionsActivity), + ) { mode -> + if (ThemePreferences.get(this@OptionsActivity) != mode) { + ThemePreferences.set(this@OptionsActivity, mode) + recreate() + } + }, lp(top = 6)) + }, lp()) + + addView(sectionHeader(getString(R.string.settings_capture)), lp()) + addView(caption(getString(R.string.settings_capture_servers_detail)), lp()) + addView(group(listOf(SupportedGamePackages.HAOPLAY, SupportedGamePackages.DARKWINTER).map { packageName -> + navigationRow( + getString(captureRegionTitle(packageName)), + ClientServerRegionPreferences(this@OptionsActivity).configured(packageName) + ?.let { Labels.region(this@OptionsActivity, it) } + ?: getString(R.string.server_region_not_configured), + R.drawable.ic_shield, + ) { chooseCaptureRegion(packageName) } + }), lp()) + val capturePreferences = CapturePreferences(this@OptionsActivity) + val payloads = PayloadHistoryPreferences(this@OptionsActivity) + addView(panel(padding = 8) { + addView(switchRow( + getString(R.string.detailed_notifications), + getString(R.string.notification_required_explanation), + capturePreferences.detailedNotifications, + ) { capturePreferences.detailedNotifications = it }, lp()) + addView(text(getString(R.string.payload_history), TextRole.LABEL) { + setPadding(dp(4), dp(20), dp(4), dp(2)) + }, lp()) + addView(caption(getString(R.string.payload_options_description)), lp()) + PayloadCatalog.categories.forEach { category -> + addView(switchRow( + title = Labels.payloadName(this@OptionsActivity, category.payloadType), + detail = Labels.payloadDescription(this@OptionsActivity, category.payloadType), + checked = category.isRequired || payloads.isEnabled(category.payloadType), + enabled = !category.isRequired, + badge = getString(R.string.required).takeIf { category.isRequired }, + ) { payloads.setEnabled(category.payloadType, it) }, lp()) + } + }, lp(top = 8)) + + addView(sectionHeader(getString(R.string.backup)), lp()) + addView(caption(getString(R.string.settings_backup_detail)), lp()) + addView(group(listOf( + navigationRow( + getString(R.string.export_platoon_backup), + getString(R.string.export_platoon_backup_detail), + R.drawable.ic_download, + enabled = scope != null, + ) { startExport(complete = false) }, + navigationRow( getString(R.string.back_up_all_information), getString(R.string.back_up_all_information_description), R.drawable.ic_save, - ::exportFullBackup, - ), matchWidth()) - - addView(TextView(context).apply { - text = getString(R.string.discord_webhook) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - val discordIcon = context.getDrawable(R.drawable.ic_discord)?.mutate() - discordIcon?.setTint(currentTextColor) - setCompoundDrawablesRelativeWithIntrinsicBounds( - discordIcon, null, null, null, - ) - compoundDrawablePadding = dp(8) - setPadding(0, spacing, 0, dp(4)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.discord_webhook_description) - textSize = 13f - setPadding(0, 0, 0, dp(8)) - }, matchWidth()) - val webhookStore = DiscordWebhookSecretStore(context) - val webhookConfigured = webhookStore.read() != null - addView(TextView(context).apply { - text = getString( - if (!webhookConfigured) { - R.string.discord_webhook_not_configured - } else { - R.string.discord_webhook_configured - }, + enabled = scope != null, + ) { startExport(complete = true) }, + navigationRow(getString(R.string.restore_full_backup), getString(R.string.restore_backup_detail), R.drawable.ic_upload) { + confirmRestore() + }, + )), lp()) + + addView(sectionHeader(getString(R.string.discord_webhook)), lp()) + val webhookRow = column() + addView(webhookRow, lp()) + renderWebhookRow(webhookRow) + + addView(sectionHeader(getString(R.string.settings_about)), lp()) + addView(group(listOf( + navigationRow( + getString(R.string.last_capture_diagnostics), + getString(R.string.last_capture_diagnostics_detail), + R.drawable.ic_bug, + ) { showDiagnostics() }, + navigationRow(getString(R.string.show_welcome_guide), null, R.drawable.ic_info_outline) { + startActivity( + Intent(this@OptionsActivity, OnboardingActivity::class.java).putExtra(OnboardingActivity.EXTRA_REPLAY, true), ) - textSize = 16f - setTypeface(typeface, Typeface.BOLD) - val backgroundColor = if (webhookConfigured) { - R.color.webhook_configured_background - } else { - R.color.webhook_missing_background - } - val foregroundColor = if (webhookConfigured) { - R.color.webhook_configured_foreground - } else { - R.color.webhook_missing_foreground - } - setTextColor(context.getColor(foregroundColor)) - background = GradientDrawable().apply { - setColor(context.getColor(backgroundColor)) - cornerRadius = dp(12).toFloat() - } - setPadding(dp(12), dp(10), dp(12), dp(10)) - }, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(8) }) - val webhookInput = EditText(context).apply { - hint = getString(R.string.discord_webhook_hint) - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI - setSingleLine(true) - } - addView(webhookInput, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = dp(4) }) - addView(Button(context).apply { - text = getString(R.string.save_discord_webhook) - usePrimaryActionStyle() - setOnClickListener { - val saved = runCatching { - webhookStore.save(webhookInput.text.toString()) - }.isSuccess - TransientMessage.show( - this@OptionsActivity, - if (saved) { - R.string.discord_webhook_saved - } else { - R.string.discord_webhook_invalid - }, - Toast.LENGTH_LONG, - ) - if (saved) recreate() - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.clear_discord_webhook) - useDestructiveTextActionStyle() - isEnabled = webhookConfigured - setOnClickListener { - val cleared = runCatching(webhookStore::clear).isSuccess - TransientMessage.show( - this@OptionsActivity, - if (cleared) R.string.discord_webhook_cleared else R.string.discord_webhook_clear_failed, - Toast.LENGTH_LONG, - ) - if (cleared) recreate() - } - }, matchWidth()) - - addView(TextView(context).apply { - text = getString(R.string.payload_history) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, dp(2)) - }, matchWidth()) - - val capturePreferences = CapturePreferences(context) - addView(packetHistoryOptionRow( - title = getString(R.string.detailed_notifications), - detail = getString(R.string.notification_required_explanation), - checked = capturePreferences.detailedNotifications, - enabled = true, - onChanged = { capturePreferences.detailedNotifications = it }, - ), matchWidth()) + }, + navigationRow(getString(R.string.app_version), versionName(), R.drawable.ic_code, showChevron = false) {}, + )), lp()) + } - PayloadCatalog.categories.forEach { category -> - addView(packetHistoryOptionRow( - title = getString( - R.string.payload_option_label, - payloadName(category.payloadType), - category.payloadType, - ), - detail = if (category.isRequired) { - getString( - R.string.required_payload_description, - payloadDescription(category.payloadType), - ) - } else { - payloadDescription(category.payloadType) - }, - checked = preferences.isEnabled(category.payloadType), - enabled = !category.isRequired, - onChanged = { preferences.setEnabled(category.payloadType, it) }, - ), matchWidth()) - } + private fun caption(value: String) = text(value, TextRole.CAPTION) { setPadding(dp(4), 0, dp(4), dp(8)) } + + private fun captureRegionTitle(packageName: String) = + if (packageName == SupportedGamePackages.HAOPLAY) R.string.haoplay_capture_region else R.string.darkwinter_capture_region + + // region Server reset + + private fun resetSummary(storageId: String): String { + val region = GameTimeZonePreferences.region(this, storageId) + val zone = GameTimeZonePreferences.deviceZone() + if (region == GameServerRegion.MANUAL) { + return getString(R.string.manual_reset_region_summary, GameTimeZonePreferences.get(this, storageId).id, zone.id) + } + val resolved = getString( + R.string.server_reset_summary, + Labels.region(this, region), + Labels.format(Labels.TIME, region.nextReset(), zone), + zone.id, + ) + return if (GameTimeZonePreferences.isAutomatic(this, storageId)) { + getString(R.string.auto_server_reset_summary, resolved) + } else { + resolved + } + } + + private fun chooseResetRegion(scope: PlatoonStorageScope) { + val regions = GameServerRegion.entries.filterNot { it == GameServerRegion.MANUAL } + 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 + Dialogs.choose( + this, + getString(R.string.server_region), + listOf(getString(R.string.server_region_auto)) + regions.map { Labels.region(this, it) }, + checked, + ) { which -> + val selected = if (which == 0) null else regions[which - 1] + val changed = if (selected == null) !automatic else automatic || selected != current + if (changed) updateResetRegion(scope, selected) + } + } - addView(TextView(context).apply { - text = getString(R.string.last_capture_diagnostics) - textSize = 15f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, dp(4)) - }, matchWidth()) - addView(TextView(context).apply { - val diagnostics = CaptureDiagnosticsStore(context).read() - text = if (diagnostics == null) { - getString(R.string.no_capture_diagnostics) + private fun updateResetRegion(scope: PlatoonStorageScope, region: GameServerRegion?) { + Feedback.show(this, R.string.rebuilding_weekly_history) + work.run( + task = { + val previousAutomatic = GameTimeZonePreferences.isAutomatic(this, scope.storageId) + val previousRegion = GameTimeZonePreferences.region(this, scope.storageId) + if (region == null) { + GameTimeZonePreferences.clearRegionOverride(this, scope.storageId) } else { - getString( - R.string.capture_diagnostics, - diagnostics.stoppedAt.atZone(ZoneId.systemDefault()).format(DIAGNOSTIC_TIME), - diagnostics.forwardedBytes / 1024, - diagnostics.inspectedBytes / 1024, - diagnostics.decodedPayloads, - diagnostics.warnings, - diagnostics.droppedChunks, - diagnostics.unknownPayloads.ifBlank { getString(R.string.none) }, - ) + GameTimeZonePreferences.setRegion(this, region, scope.storageId) + } + try { + PlatoonRepository(this, scope) + .rebuildWeeklyHistoryForTimeZoneChange(GameTimeZonePreferences.get(this, scope.storageId)) + } catch (error: Exception) { + runCatching { + if (previousAutomatic) { + GameTimeZonePreferences.clearRegionOverride(this, scope.storageId) + } else { + GameTimeZonePreferences.setRegion(this, previousRegion, scope.storageId) + } + }.exceptionOrNull()?.let(error::addSuppressed) + throw error } - setTextIsSelectable(true) - }, matchWidth()) + }, + ) { result -> + Feedback.show(this, if (result.isSuccess) R.string.game_timezone_updated else R.string.game_timezone_update_failed) + recreate() } - return ScrollView(this).apply { addView(container, matchWidth()) } } - /** Keeps a packet-history choice and its explanation in one compact visual group. */ - private fun packetHistoryOptionRow( - title: CharSequence, - detail: CharSequence, - checked: Boolean, - enabled: Boolean, - onChanged: (Boolean) -> Unit, - ): View = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.TOP - minimumHeight = dp(52) - setPadding(0, dp(2), 0, dp(2)) - val checkBox = CheckBox(context).apply { - contentDescription = title - isChecked = checked - isEnabled = enabled - if (!enabled) { - buttonTintList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), - intArrayOf(getColor(R.color.primary), getColor(R.color.primary)), - ) - } - setOnCheckedChangeListener { _, value -> onChanged(value) } - } - addView(checkBox, LinearLayout.LayoutParams(dp(48), dp(48))) - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - addView(TextView(context).apply { - text = title - textSize = 14f - setTextColor(getColor(if (enabled) R.color.text_primary else R.color.text_secondary)) - }, matchWidth()) - addView(TextView(context).apply { - text = detail - textSize = 12f - setTextColor(getColor(R.color.text_secondary)) - setLineSpacing(0f, 1.08f) - setPadding(0, dp(2), 0, 0) - }, matchWidth()) - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { - topMargin = dp(4) - }) - if (enabled) { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - setOnClickListener { checkBox.performClick() } + private fun chooseCaptureRegion(packageName: String) { + val preferences = ClientServerRegionPreferences(this) + val regions = preferences.allowed(packageName) + Dialogs.choose( + this, + getString(captureRegionTitle(packageName)), + regions.map { Labels.region(this, it) }, + regions.indexOf(preferences.configured(packageName)), + ) { which -> + preferences.set(packageName, regions[which]) + recreate() } } - private fun confirmFullRestore() { + // endregion + + // region Backup + + private fun startExport(complete: Boolean) { if (CaptureStatus.isRunning) { - showBackupMessage(R.string.stop_capture_before_backup) + Feedback.show(this, R.string.stop_capture_before_backup, long = true) return } - android.app.AlertDialog.Builder(this) - .setTitle(R.string.restore_full_backup) - .setMessage(R.string.restore_full_backup_warning) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.restore_full_backup) { _, _ -> openFullBackup() } - .show() + pendingExportComplete = complete + val prefix = if (complete) "GF2logger-full" else "GF2logger-platoon" + exportBackup.launch("$prefix-${Labels.FILE_STAMP.format(LocalDateTime.now())}.${PlatoonBackupManager.FILE_EXTENSION}") } - @Suppress("DEPRECATION") - private fun openFullBackup() { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("*/*") - .putExtra(Intent.EXTRA_MIME_TYPES, BACKUP_MIME_TYPES) - startActivityForResult(intent, REQUEST_FULL_BACKUP_RESTORE) - } - - @Suppress("DEPRECATION") - private fun exportFullBackup() { - if (requireActiveScope() == null) return + private fun confirmRestore() { if (CaptureStatus.isRunning) { - showBackupMessage(R.string.stop_capture_before_backup) + Feedback.show(this, R.string.stop_capture_before_backup, long = true) return } - val title = "mobileGF2logger-full-${BACKUP_TIME.format(LocalDateTime.now())}." + - PlatoonBackupManager.FILE_EXTENSION - val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType(PlatoonBackupManager.MIME_TYPE) - .putExtra(Intent.EXTRA_TITLE, title) - startActivityForResult(intent, REQUEST_FULL_BACKUP_EXPORT) + Dialogs.confirm(this, R.string.restore_full_backup, R.string.restore_backup_warning, R.string.choose_file) { + restoreBackup.launch(arrayOf(PlatoonBackupManager.MIME_TYPE, "application/zip", "application/octet-stream")) + } } - private fun hasBackupExtension(uri: Uri): Boolean { - val name = contentResolver.query( - uri, - arrayOf(OpenableColumns.DISPLAY_NAME), - null, - null, - null, - )?.use { cursor: Cursor -> - if (!cursor.moveToFirst()) null else cursor.getString(0) - } ?: return false - return BackupFileName.isValid(name) + private fun restore(uri: Uri) { + if (DocumentAccess.displayName(contentResolver, uri)?.let(BackupFileName::isValid) != true) { + Feedback.show(this, R.string.invalid_full_backup, long = true) + return + } + runBackup(R.string.backup_restoring, R.string.full_backup_restored, ::fullBackupRestoreFailureMessage, reload = true) { + DocumentAccess.openInput(contentResolver, uri).use { input -> + PlatoonBackupManager.restoreSelected(this, input, complete = true) + } + } } - private fun runBackupOperation( - successMessage: Int, - failureMessage: (Throwable) -> Int, + private fun runBackup( + progress: Int, + success: Int, + failure: (Throwable) -> Int, + reload: Boolean, operation: () -> Unit, ) { - fileIoExecutor.execute { - val message = runCatching(operation).fold( - onSuccess = { successMessage }, - onFailure = failureMessage, - ) - mainHandler.post { - if (!isFinishing && !isDestroyed) { - showBackupMessage(message) - if (message == R.string.full_backup_restored) recreate() - } - } + Feedback.show(this, progress) + work.run(task = operation) { result -> + Feedback.show(this, result.fold(onSuccess = { success }, onFailure = failure), long = true) + if (result.isSuccess && reload) recreate() } } - private fun showBackupMessage(message: Int) { - TransientMessage.show(this, message, android.widget.Toast.LENGTH_LONG) - } - - private fun requireActiveScope(): dev.gf2log.app.management.PlatoonStorageScope? = - profileBinding.scope.also { scope -> - if (scope == null) showBackupMessage(R.string.no_platoon_detected_detail) + // endregion + + // region Discord, diagnostics, about + + private fun renderWebhookRow(container: LinearLayout) { + work.run(task = { DiscordWebhookSecretStore(this).read() != null }) { result -> + val configured = result.getOrDefault(false) + container.removeAllViews() + container.addView(group(listOf( + navigationRow( + getString(if (configured) R.string.discord_webhook_configured else R.string.discord_webhook_not_configured), + getString(R.string.discord_webhook_summary), + R.drawable.ic_discord, + trailing = getString(if (configured) R.string.change else R.string.set_up), + ) { editWebhook(container, configured) }, + )), lp()) } - - private fun changeLanguage(language: String) { - if (LanguagePreferences.get(this) == language) return - LanguagePreferences.set(this, language) - recreate() - } - - private fun changeTheme(mode: String) { - if (ThemePreferences.get(this) == mode) return - ThemePreferences.set(this, mode) - recreate() } - private fun chooseGameServerRegion() { - val storageScope = PlatoonProfileRegistry(this).activeScope() - val regions = GameServerRegion.entries.filterNot { it == GameServerRegion.MANUAL } - val choices = listOf(getString(R.string.server_region_auto)) + regions.map(::regionLabel) - val automatic = GameTimeZonePreferences.isAutomatic(this, storageScope.storageId) - val current = GameTimeZonePreferences.region(this, storageScope.storageId) - val checked = if (automatic) 0 else regions.indexOf(current).takeIf { it >= 0 }?.plus(1) ?: -1 - AlertDialog.Builder(this) - .setTitle(R.string.server_region) - .setSingleChoiceItems( - choices.toTypedArray(), - checked, - ) { dialog, which -> - dialog.dismiss() - if (which == 0) { - if (!automatic) { - updateGameTimeZone(storageScope, null) + private fun editWebhook(container: LinearLayout, configured: Boolean) { + val input = textField( + getString(R.string.discord_webhook_hint), + id = R.id.webhook_input, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI, + ) + val error = text(null, TextRole.CAPTION) { + setTextColor(getColor(R.color.destructive_action)) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + visibility = View.GONE + } + val builder = Dialogs.builder(this) + .setTitle(R.string.discord_webhook) + .setView(Dialogs.content(this) { + addView(text(getString(R.string.discord_webhook_description), TextRole.SECONDARY), lp()) + addView(input, lp(top = 12)) + addView(error, lp(top = 4)) + }) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.save_discord_webhook, null) + if (configured) builder.setNeutralButton(R.string.clear_discord_webhook, null) + Dialogs.show(builder) { dialog -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val value = input.text.toString() + work.run(task = { DiscordWebhookSecretStore(this).save(value) }) { result -> + if (result.isSuccess) { + dialog.dismiss() + Feedback.show(this, R.string.discord_webhook_saved) + renderWebhookRow(container) + } else { + error.text = getString(R.string.discord_webhook_invalid) + error.visibility = View.VISIBLE } - } else { - val selected = regions[which - 1] - if (automatic || selected != current) updateGameTimeZone(storageScope, selected) } } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun chooseClientServerRegion(packageName: String) { - val preferences = ClientServerRegionPreferences(this) - val regions = preferences.allowed(packageName) - val current = preferences.configured(packageName) - AlertDialog.Builder(this) - .setTitle( - if (packageName == SupportedGamePackages.HAOPLAY) { - R.string.haoplay_capture_region - } else { - R.string.darkwinter_capture_region - }, - ) - .setSingleChoiceItems( - regions.map(::regionLabel).toTypedArray(), - regions.indexOf(current), - ) { dialog, which -> - preferences.set(packageName, regions[which]) - dialog.dismiss() - recreate() + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.apply { + setTextColor(getColor(R.color.destructive_action)) + setOnClickListener { + work.run(task = { DiscordWebhookSecretStore(this@OptionsActivity).clear() }) { result -> + dialog.dismiss() + Feedback.show( + this@OptionsActivity, + if (result.isSuccess) R.string.discord_webhook_cleared else R.string.discord_webhook_clear_failed, + ) + renderWebhookRow(container) + } + } } - .setNegativeButton(android.R.string.cancel, null) - .show() + } } - private fun updateGameTimeZone( - storageScope: PlatoonStorageScope, - region: GameServerRegion?, - ) { - fileIoExecutor.execute { - val previousAutomatic = GameTimeZonePreferences.isAutomatic(this, storageScope.storageId) - val previousRegion = GameTimeZonePreferences.region(this, storageScope.storageId) - val result = runCatching { - if (region == null) { - GameTimeZonePreferences.clearRegionOverride(this, storageScope.storageId) - } else { - GameTimeZonePreferences.setRegion(this, region, storageScope.storageId) - } - val zoneId = GameTimeZonePreferences.get(this, storageScope.storageId) - try { - PlatoonRepository(this, storageScope) - .rebuildWeeklyHistoryForTimeZoneChange(zoneId) - } catch (error: Exception) { - runCatching { - if (previousAutomatic) { - GameTimeZonePreferences.clearRegionOverride(this, storageScope.storageId) - } else { - GameTimeZonePreferences.setRegion( - this, - previousRegion, - storageScope.storageId, - ) - } - }.exceptionOrNull()?.let(error::addSuppressed) - throw error - } - } - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - TransientMessage.show( - this, - if (result.isSuccess) R.string.game_timezone_updated - else R.string.game_timezone_update_failed, - Toast.LENGTH_SHORT, + private fun showDiagnostics() { + work.run(task = { CaptureDiagnosticsStore(this).read() }) { result -> + val diagnostics = result.getOrNull() + val message = if (diagnostics == null) { + getString(R.string.no_capture_diagnostics) + } else { + getString( + R.string.capture_diagnostics, + Labels.format(Labels.DATE_TIME_SECONDS, diagnostics.stoppedAt, ZoneId.systemDefault()), + diagnostics.forwardedBytes / 1024, + diagnostics.inspectedBytes / 1024, + diagnostics.decodedPayloads, + diagnostics.warnings, + diagnostics.droppedChunks, + diagnostics.unknownPayloads.ifBlank { getString(R.string.none) }, ) - recreate() } + val builder = Dialogs.builder(this) + .setTitle(R.string.last_capture_diagnostics) + .setMessage(message) + .setPositiveButton(R.string.done, null) + if (diagnostics != null) { + builder.setNeutralButton(R.string.copy) { _, _ -> + getSystemService(ClipboardManager::class.java) + .setPrimaryClip(ClipData.newPlainText(getString(R.string.last_capture_diagnostics), message)) + Feedback.show(this, R.string.copied) + } + } + Dialogs.show(builder) } } - private fun resetSummary(region: GameServerRegion, automatic: Boolean): String { - if (region == GameServerRegion.MANUAL) { - return getString( - R.string.manual_reset_region_summary, - GameTimeZonePreferences.get(this).id, - GameTimeZonePreferences.deviceZone().id, - ) + private fun versionName(): String = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)).versionName + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, 0).versionName } - val localReset = region.nextReset() - .atZone(GameTimeZonePreferences.deviceZone()) - .format(RESET_LOCAL_TIME) - val resolved = getString( - R.string.server_reset_summary, - regionLabel(region), - localReset, - GameTimeZonePreferences.deviceZone().id, - ) - return if (automatic) getString(R.string.auto_server_reset_summary, resolved) else resolved - } + }.getOrNull().orEmpty() - private fun regionLabel(region: GameServerRegion): String = getString( - when (region) { - GameServerRegion.MANUAL -> R.string.server_region_manual - GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global - GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china - GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global - GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan - GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea - GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia - }, - ) - - private fun clientRegionLabel(packageName: String): String = - ClientServerRegionPreferences(this).configured(packageName) - ?.let(::regionLabel) - ?: getString(R.string.server_region_not_configured) - - private fun payloadName(payloadType: Int): String = getString( - when (payloadType) { - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> R.string.payload_name_platoon_profile - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> R.string.payload_name_platoon_members - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> R.string.payload_name_platoon_activity - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> R.string.payload_name_platoon_updates - Gfl2PayloadDecoder.TYPE_WEAPONS -> R.string.payload_name_weapons - Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> R.string.payload_name_attachments - Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> R.string.payload_name_common_keys - Gfl2PayloadDecoder.TYPE_FORMATIONS -> R.string.payload_name_formations - else -> R.string.unknown_payload_type - }, - ) - - private fun payloadDescription(payloadType: Int): String = getString( - when (payloadType) { - Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE -> - R.string.payload_description_platoon_profile - Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> R.string.payload_description_platoon_members - Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY -> - R.string.payload_description_platoon_activity - Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES -> - R.string.payload_description_platoon_updates - Gfl2PayloadDecoder.TYPE_WEAPONS -> R.string.payload_description_weapons - Gfl2PayloadDecoder.TYPE_WEAPON_MODS -> R.string.payload_description_attachments - Gfl2PayloadDecoder.TYPE_PUBLIC_SKILL_ITEMS -> R.string.payload_description_common_keys - Gfl2PayloadDecoder.TYPE_FORMATIONS -> R.string.payload_description_formations - else -> R.string.unknown_payload_type - }, - ) - - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - - private fun matchWidth(): ViewGroup.LayoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - - companion object { - private val DIAGNOSTIC_TIME = DateTimeFormatter.ofPattern("yy/MM/dd HH:mm:ss") - private val RESET_LOCAL_TIME = DateTimeFormatter.ofPattern("HH:mm") - private val BACKUP_TIME = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") - private val BACKUP_MIME_TYPES = arrayOf( - PlatoonBackupManager.MIME_TYPE, - "application/zip", - "application/octet-stream", - ) - private const val REQUEST_FULL_BACKUP_EXPORT = 301 - private const val REQUEST_FULL_BACKUP_RESTORE = 302 + // endregion + + private companion object { + const val STATE_EXPORT_COMPLETE = "settings.export_complete" } } -// Function Name: fullBackupRestoreFailureMessage -// Description: -// - Keeps known backup validation failures distinct from operational restore failures. -// - Prevents storage, document-provider, and unexpected errors from blaming the selected file. -// Parameters: -// - error: Failure raised while opening, validating, or restoring a complete backup. -// Returns: -// - Returns the invalid-backup message for validation failures. -// - Returns the operational restore-failure message for every other failure. +/** Known validation failures blame the selected file; anything else is an operational failure. */ internal fun fullBackupRestoreFailureMessage(error: Throwable): Int = - if (error is InvalidBackupException) { - R.string.invalid_full_backup - } else { - R.string.full_backup_restore_failed - } + if (error is InvalidBackupException) R.string.invalid_full_backup else R.string.full_backup_restore_failed diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index 0f73e07..ca854d4 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -1,291 +1,201 @@ package dev.gf2log.app -import android.app.AlertDialog -import android.content.Intent -import android.content.res.ColorStateList import android.content.ClipData import android.content.ClipboardManager +import android.content.Intent import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.view.Gravity import android.view.View -import android.view.ViewGroup -import android.widget.Button import android.widget.FrameLayout import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TableLayout import android.widget.TableRow -import android.widget.TextView -import android.widget.Toast -import dev.gf2log.app.history.CaptureHistoryStore -import dev.gf2log.app.history.SavedHistoryStore import dev.gf2log.app.discord.DiscordWebhookSecretStore import dev.gf2log.app.discord.DiscordWebhookSender import dev.gf2log.app.discord.OriginalCsvPayload -import java.util.concurrent.Executors -import dev.gf2log.protocol.ParsedPacketTableParser -import java.io.File +import dev.gf2log.app.history.CaptureHistoryStore +import dev.gf2log.app.history.SavedHistoryStore import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.management.PlatoonStorageScope +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.Feedback +import dev.gf2log.app.ui.MATCH +import dev.gf2log.app.ui.MenuEntry +import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.StateViews +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.text +import dev.gf2log.protocol.ParsedPacketTableParser +import java.io.File +/** One parsed packet as a bounded table or its complete decoded text. */ class PacketHistoryActivity : BaseActivity() { - private lateinit var actionButton: Button - private lateinit var contentContainer: LinearLayout - private lateinit var sendButton: Button - private lateinit var sendUnavailableOverlay: View - private lateinit var rawContent: String + + private lateinit var screen: Screen + private val work = BackgroundWork(this, "GF2PacketHistory") + private val body by lazy { FrameLayout(this) } + private var rawContent: String? = null private var table: ParsedPacketTableParser.Table? = null private var showingRaw = false - private val sendExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "GF2DiscordWebhook") - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val entryId = intent.getStringExtra(EXTRA_ENTRY_ID).orEmpty() - val title = intent.getStringExtra(EXTRA_ENTRY_TITLE).orEmpty() val storageId = intent.getStringExtra(EXTRA_STORAGE_ID) - val active = PlatoonProfileRegistry(this).active() - if (storageId == null || active?.storageId != storageId) { + if (storageId == null || PlatoonProfileRegistry(this).active()?.storageId != storageId) { finish() return } - val historyRoot = PlatoonStorageScope(storageId).rootDirectory(this) - rawContent = if (intent.getBooleanExtra(EXTRA_SAVED_ENTRY, false)) { - SavedHistoryStore( - File(historyRoot, SavedHistoryStore.SAVED_HISTORY_DIRECTORY), - ).read(entryId) - } else { - CaptureHistoryStore( - File(historyRoot, CaptureHistoryStore.HISTORY_DIRECTORY), - ).read(entryId) - } ?: run { - finish() - return - } - table = ParsedPacketTableParser.parse(rawContent) - - val spacing = (16 * resources.displayMetrics.density).toInt() - val container = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(spacing, spacing, spacing, spacing) - addView(TextView(context).apply { - text = title - textSize = 24f - setTypeface(typeface, Typeface.BOLD) - }, matchWidth()) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - actionButton = Button(context).apply { - useSecondaryActionStyle() - setOnClickListener { - if (showingRaw) copyToClipboard(rawContent) else showRawContent() - } - } - addView(actionButton, LinearLayout.LayoutParams( - 0, - dp(48), - 1f, - ).apply { marginEnd = dp(4) }) - sendButton = Button(context).apply { - text = getString(R.string.send_original_csv) - usePrimaryActionStyle() - setCompoundDrawablesRelativeWithIntrinsicBounds( - R.drawable.ic_discord, 0, 0, 0, - ) - compoundDrawableTintList = ColorStateList.valueOf( - context.getColor(R.color.primary_action_foreground), - ) - compoundDrawablePadding = dp(6) - setOnClickListener { confirmSendOriginalCsv() } + showingRaw = savedInstanceState?.getBoolean(STATE_RAW) == true + screen = Screen(this) + screen.topBar.setNavigation(::finish).setTitle(intent.getStringExtra(EXTRA_ENTRY_TITLE).orEmpty()) + screen.setBody(column { + addView(body, lp(height = 0, weight = 1f)) + }) + body.addView(StateViews.loading(this, getString(R.string.loading_packet))) + val root = PlatoonStorageScope(storageId).rootDirectory(this) + val saved = intent.getBooleanExtra(EXTRA_SAVED_ENTRY, false) + work.run( + task = { + val content = if (saved) { + SavedHistoryStore(File(root, SavedHistoryStore.SAVED_HISTORY_DIRECTORY)).read(entryId) + } else { + CaptureHistoryStore(File(root, CaptureHistoryStore.HISTORY_DIRECTORY)).read(entryId) } - addView(FrameLayout(context).apply { - addView( - sendButton, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ), - ) - sendUnavailableOverlay = View(context).apply { - isClickable = true - isFocusable = true - contentDescription = getString(R.string.discord_webhook_not_configured) - setOnClickListener { showWebhookRequiredDialog() } - } - addView( - sendUnavailableOverlay, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ), - ) - }, LinearLayout.LayoutParams( - 0, - dp(48), - 1f, - ).apply { marginStart = dp(4) }) - }, matchWidth()) - contentContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - setPadding(0, spacing, 0, 0) + content?.let { it to ParsedPacketTableParser.parse(it) } + }, + ) { result -> + val loaded = result.getOrNull() + if (loaded == null) { + body.removeAllViews() + body.addView(StateViews.empty(this, R.drawable.ic_warning, getString(R.string.packet_unavailable))) + return@run } - addView(contentContainer, matchWidth()) + rawContent = loaded.first + table = loaded.second + if (table == null) showingRaw = true + render() } - - setContentView(ScrollView(this).apply { addView(container, matchWidth()) }) - if (table == null) showRawContent() else showTable() - refreshSendAvailability() - } - - - override fun onScreenResumed() { - if (::sendButton.isInitialized) refreshSendAvailability() - } - - private fun refreshSendAvailability() { - val hasCsvTable = table != null - val webhookConfigured = runCatching { - DiscordWebhookSecretStore(this).read() != null - }.getOrDefault(false) - val unavailable = hasCsvTable && !webhookConfigured - sendButton.isEnabled = hasCsvTable && webhookConfigured - sendButton.alpha = if (sendButton.isEnabled) 1f else 0.5f - sendUnavailableOverlay.visibility = if (unavailable) View.VISIBLE else View.GONE - } - - private fun showWebhookRequiredDialog() { - AlertDialog.Builder(this) - .setTitle(R.string.discord_webhook_not_configured) - .setMessage(R.string.discord_webhook_required) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.open_options) { _, _ -> - startActivity(Intent(this, OptionsActivity::class.java)) - } - .show() } - override fun onDestroy() { - sendExecutor.shutdownNow() - super.onDestroy() + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(STATE_RAW, showingRaw) + super.onSaveInstanceState(outState) } - private fun confirmSendOriginalCsv() { - val csv = runCatching { OriginalCsvPayload.extract(rawContent) }.getOrElse { - TransientMessage.show(this, R.string.discord_csv_invalid, Toast.LENGTH_LONG) - return - } - val webhook = DiscordWebhookSecretStore(this).read() ?: run { - showWebhookRequiredDialog() - return - } - AlertDialog.Builder(this) - .setTitle(R.string.send_original_csv) - .setMessage(R.string.send_original_csv_warning) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.send_original_csv) { _, _ -> - sendOriginalCsv(webhook, csv) + private fun render() { + val content = rawContent ?: return + val hasTable = table != null + screen.topBar.setActions( + listOf(BarAction(R.drawable.ic_copy, getString(R.string.copy_parsed_packet)) { copy(content) }), + overflow = listOf(MenuEntry(getString(R.string.send_original_csv), hasTable) { confirmSend(content) }), + ) + body.removeAllViews() + body.addView(column { + if (hasTable) { + addView(Controls.segmentedControl( + context = this@PacketHistoryActivity, + options = listOf(TABLE to getString(R.string.packet_view_table), RAW to getString(R.string.packet_view_raw)), + selectedValue = if (showingRaw) RAW else TABLE, + ) { + showingRaw = it == RAW + render() + }, lp(start = 16, end = 16, top = 4, bottom = 8)) } - .show() + addView(if (showingRaw) rawView(content) else tableView(requireNotNull(table)), lp(height = 0, weight = 1f)) + }, FrameLayout.LayoutParams(MATCH, MATCH)) } - // Function Name: sendOriginalCsv - // Description: - // - Sends only the validated original CSV body after explicit confirmation. - // - Runs bounded HTTPS I/O away from the main thread and never logs the secret URL. - // Parameters: - // - webhook: Decrypted, policy-validated Discord incoming-webhook URL. - // - csv: Validated original CSV body with history metadata removed. - // Returns: - // - Unit after scheduling the request and result notification. - private fun sendOriginalCsv(webhook: String, csv: String) { - sendButton.alpha = 0.5f - sendButton.isEnabled = false - TransientMessage.show(this, R.string.discord_csv_sending) - sendExecutor.execute { - val sent = runCatching { DiscordWebhookSender().send(webhook, csv) }.isSuccess - runOnUiThread { - if (isFinishing || isDestroyed) return@runOnUiThread - refreshSendAvailability() - TransientMessage.show( - this, - if (sent) R.string.discord_csv_sent else R.string.discord_csv_send_failed, - Toast.LENGTH_LONG, - ) - } - } + private fun rawView(content: String) = ScrollView(this).apply { + clipToPadding = false + setPadding(dp(16), 0, dp(16), dp(24)) + addView(text(content, TextRole.BODY) { + typeface = Typeface.MONOSPACE + textSize = 12f + setTextIsSelectable(true) + }) } - private fun showTable() { - val parsed = table ?: return - showingRaw = false - actionButton.text = getString(R.string.show_raw_csv_file) - contentContainer.removeAllViews() - contentContainer.addView(HorizontalScrollView(this).apply { - isFillViewport = true + private fun tableView(parsed: ParsedPacketTableParser.Table) = ScrollView(this).apply { + clipToPadding = false + setPadding(0, 0, 0, dp(24)) + addView(HorizontalScrollView(context).apply { + clipToPadding = false + setPadding(dp(16), 0, dp(16), 0) addView(TableLayout(context).apply { - isStretchAllColumns = false + setBackgroundColor(getColor(R.color.outline)) + setPadding(1, 1, 1, 1) addView(tableRow(parsed.header, header = true)) parsed.rows.forEach { addView(tableRow(it, header = false)) } }) - }, matchWidth()) - } - - private fun showRawContent() { - showingRaw = true - actionButton.text = getString(R.string.copy_parsed_packet) - contentContainer.removeAllViews() - contentContainer.addView(TextView(this).apply { - text = rawContent - textSize = 13f - typeface = Typeface.MONOSPACE - setTextIsSelectable(true) - }, matchWidth()) + }, FrameLayout.LayoutParams(MATCH, WRAP)) } private fun tableRow(values: List, header: Boolean): TableRow = TableRow(this).apply { values.forEach { value -> - addView(TextView(context).apply { - text = value - textSize = if (header) 14f else 13f + addView(text(value, if (header) TextRole.LABEL else TextRole.BODY) { + if (header) setTextColor(getColor(R.color.text_primary)) + textSize = 13f gravity = Gravity.CENTER_VERTICAL - setTextColor(getColor(R.color.text_primary)) - if (header) setTypeface(typeface, Typeface.BOLD) - val horizontal = (10 * resources.displayMetrics.density).toInt() - val vertical = (8 * resources.displayMetrics.density).toInt() - setPadding(horizontal, vertical, horizontal, vertical) - background = GradientDrawable().apply { - setColor(getColor(if (header) R.color.table_header else R.color.table_cell)) - setStroke(1, getColor(R.color.outline_strong)) - } - }, TableRow.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - )) + setPadding(dp(10), dp(8), dp(10), dp(8)) + setBackgroundColor(getColor(if (header) R.color.table_header else R.color.table_cell)) + setTextIsSelectable(!header) + }, TableRow.LayoutParams(WRAP, MATCH).apply { setMargins(0, 0, 1, 1) }) } } - private fun copyToClipboard(content: String) { + private fun copy(content: String) { getSystemService(ClipboardManager::class.java) .setPrimaryClip(ClipData.newPlainText(getString(R.string.clipboard_label), content)) - TransientMessage.show(this, R.string.status_packet_copied) + Feedback.show(this, R.string.status_packet_copied) } - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - - private fun matchWidth(): ViewGroup.LayoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + private fun confirmSend(content: String) { + val csv = runCatching { OriginalCsvPayload.extract(content) }.getOrElse { + Feedback.show(this, R.string.discord_csv_invalid, long = true) + return + } + work.run(task = { DiscordWebhookSecretStore(this).read() }) { result -> + val webhook = result.getOrNull() + if (webhook == null) { + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.discord_webhook_not_configured) + .setMessage(R.string.discord_webhook_required) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.open_options) { _, _ -> + startActivity(Intent(this, OptionsActivity::class.java)) + }, + ) + return@run + } + Dialogs.confirm(this, R.string.send_original_csv, R.string.send_original_csv_warning, R.string.send) { + Feedback.show(this, R.string.discord_csv_sending) + work.run(task = { DiscordWebhookSender().send(webhook, csv) }) { sent -> + Feedback.show(this, if (sent.isSuccess) R.string.discord_csv_sent else R.string.discord_csv_send_failed, long = true) + } + } + } + } companion object { const val EXTRA_ENTRY_ID = "entry_id" const val EXTRA_ENTRY_TITLE = "entry_title" const val EXTRA_SAVED_ENTRY = "saved_entry" const val EXTRA_STORAGE_ID = "storage_id" + private const val TABLE = "table" + private const val RAW = "raw" + private const val STATE_RAW = "packet.raw" } } diff --git a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt index 7443e68..3ba72f9 100644 --- a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt @@ -3,215 +3,133 @@ package dev.gf2log.app import android.app.Activity import android.app.AlertDialog import android.content.Intent -import android.graphics.Typeface import android.text.TextUtils -import android.view.Gravity -import android.view.ViewGroup -import android.widget.LinearLayout +import android.view.View import android.widget.RadioButton import android.widget.RadioGroup -import android.widget.TextView import dev.gf2log.app.capture.CaptureVpnService import dev.gf2log.app.capture.PendingPlatoonAdmissionStore import dev.gf2log.app.management.PlatoonClient import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.text import java.lang.ref.WeakReference -/** Presents the process-memory admission gate without moving capture policy into an Activity. */ +/** + * Presents the process-memory admission gate for a newly detected Platoon. Screens observe the + * pending store, so the prompt appears as soon as a candidate arrives and closes when it is + * confirmed or discarded elsewhere. + */ internal object PendingPlatoonAdmissionPrompt { - private var visibleDialog = WeakReference(null) + private var visible = WeakReference(null) + private var visibleToken: String? = null + private val selections = HashMap() - fun showIfNeeded(activity: Activity) { + fun onCandidatesChanged(activity: Activity, candidates: List) { if (activity.isFinishing || activity.isDestroyed) return - if (visibleDialog.get()?.isShowing == true) return - val candidate = PendingPlatoonAdmissionStore.summaries().firstOrNull() ?: return - show(activity, candidate) + val showing = visible.get()?.takeIf { it.isShowing } + if (showing != null) { + if (candidates.none { it.token == visibleToken }) showing.dismiss() + return + } + selections.keys.retainAll(candidates.mapTo(mutableSetOf()) { it.token }) + candidates.firstOrNull()?.let { show(activity, it) } + } + + fun dismiss() { + visible.get()?.dismiss() + visible.clear() + visibleToken = null } - private fun show( - activity: Activity, - candidate: PendingPlatoonAdmissionStore.Summary, - ) { + private fun show(activity: Activity, candidate: PendingPlatoonAdmissionStore.Summary) { val client = PlatoonClient.fromPackage(candidate.ownerPackage) ?: return val regions = ClientServerRegionPreferences.allowedFor(candidate.ownerPackage) - var selected: GameServerRegion? = null - val content = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(activity.dp(20), activity.dp(4), activity.dp(20), 0) - addView(TextView(context).apply { - text = activity.getString(R.string.new_platoon_detected_message) - textSize = 14f - setTextColor(context.getColor(R.color.text_secondary)) - }, matchWidth()) - addView(profilePanel(activity, candidate, client), matchWidth().apply { - topMargin = activity.dp(16) - bottomMargin = activity.dp(12) - }) - addView(TextView(context).apply { - text = activity.getString(R.string.choose_server_region) - textSize = 15f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - }, matchWidth()) - addView(RadioGroup(context).apply { - orientation = RadioGroup.VERTICAL - regions.forEach { region -> - addView(RadioButton(context).apply { - id = android.view.View.generateViewId() - text = activity.serverRegionLabel(region) - textSize = 14f - minimumHeight = activity.dp(48) - setOnCheckedChangeListener { _, checked -> - if (checked) selected = region - } - }, matchWidth()) - } - }, matchWidth().apply { topMargin = activity.dp(4) }) - addView(TextView(context).apply { - text = activity.getString(R.string.new_platoon_memory_only_notice) - textSize = 12f - setTextColor(context.getColor(R.color.text_secondary)) - setPadding(0, activity.dp(8), 0, 0) - }, matchWidth()) + val radios = RadioGroup(activity).apply { orientation = RadioGroup.VERTICAL } + val content = Dialogs.content(activity) { + addView(activity.text(activity.getString(R.string.new_platoon_detected_message), TextRole.SECONDARY), activity.lp()) + addView(activity.panel(padding = 12) { + addView(activity.text(candidate.profile.platoonName, TextRole.TITLE) { + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + }, activity.lp()) + addView(activity.text( + activity.getString(R.string.pending_platoon_identity, client.displayName, candidate.profile.platoonId.toLong()), + TextRole.CAPTION, + ), activity.lp(top = 2)) + }, activity.lp(top = 12)) + addView(activity.text(activity.getString(R.string.choose_server_region), TextRole.SUBTITLE), activity.lp(top = 16)) + addView(radios, activity.lp(top = 4)) + addView(activity.text(activity.getString(R.string.new_platoon_memory_only_notice), TextRole.CAPTION), activity.lp(top = 8)) } - val dialog = AlertDialog.Builder(activity) - .setTitle(R.string.new_platoon_detected) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.confirm, null) - .setCancelable(false) - .create() - visibleDialog = WeakReference(dialog) - dialog.setOnShowListener { - val confirm = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { - isEnabled = false - usePrimaryActionStyle() + val dialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.new_platoon_detected) + .setView(content) + .setCancelable(false) + .setNegativeButton(R.string.discard, null) + .setPositiveButton(R.string.confirm, null), + ) { shown -> + val confirm = shown.getButton(AlertDialog.BUTTON_POSITIVE) + val ids = regions.map { region -> + RadioButton(activity).apply { + id = View.generateViewId() + text = Labels.region(activity, region) + textSize = 14f + setTextColor(activity.getColor(R.color.text_primary)) + buttonTintList = Controls.controlTint(activity) + minimumHeight = activity.dp(48) + isChecked = selections[candidate.token] == region + }.also { radios.addView(it) }.id } - val radioGroup = content.getChildAt(3) as RadioGroup - radioGroup.setOnCheckedChangeListener { _, checkedId -> - val index = (0 until radioGroup.childCount) - .indexOfFirst { radioGroup.getChildAt(it).id == checkedId } - selected = regions.getOrNull(index) - confirm.isEnabled = selected != null + confirm.isEnabled = candidate.token in selections + radios.setOnCheckedChangeListener { _, checkedId -> + regions.getOrNull(ids.indexOf(checkedId))?.let { selections[candidate.token] = it } + confirm.isEnabled = candidate.token in selections } confirm.setOnClickListener { - val region = selected ?: return@setOnClickListener - if (!PendingPlatoonAdmissionStore.contains(candidate.token)) { - dialog.dismiss() - showIfNeeded(activity) - return@setOnClickListener - } + val region = selections[candidate.token] ?: return@setOnClickListener activity.startService( Intent(activity, CaptureVpnService::class.java) .setAction(CaptureVpnService.ACTION_CONFIRM_PENDING_PLATOON) .putExtra(CaptureVpnService.EXTRA_PENDING_TOKEN, candidate.token) .putExtra(CaptureVpnService.EXTRA_SERVER_REGION, region.storedValue), ) - dialog.dismiss() - refreshAfterAdmission(activity, candidate.token) - } - dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { - confirmDiscard(activity, dialog, candidate) + confirm.isEnabled = false } + shown.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { confirmDiscard(activity, candidate) } } dialog.setOnDismissListener { - if (visibleDialog.get() === dialog) visibleDialog.clear() + if (visible.get() === dialog) { + visible.clear() + visibleToken = null + } } - dialog.show() - } - - private fun profilePanel( - activity: Activity, - candidate: PendingPlatoonAdmissionStore.Summary, - client: PlatoonClient, - ) = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - background = ModernUi.panelBackground(context) - setPadding(activity.dp(14), activity.dp(12), activity.dp(14), activity.dp(12)) - addView(TextView(context).apply { - text = candidate.profile.platoonName - textSize = 17f - typeface = Typeface.create("sans-serif-medium", Typeface.BOLD) - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - }, matchWidth()) - addView(TextView(context).apply { - text = activity.getString( - R.string.pending_platoon_identity, - client.displayName, - candidate.profile.platoonId.toLong(), - ) - textSize = 13f - setTextColor(context.getColor(R.color.text_secondary)) - setPadding(0, activity.dp(4), 0, 0) - }, matchWidth()) + visible = WeakReference(dialog) + visibleToken = candidate.token } - private fun confirmDiscard( - activity: Activity, - parent: AlertDialog, - candidate: PendingPlatoonAdmissionStore.Summary, - ) { - val warning = AlertDialog.Builder(activity) - .setTitle(R.string.discard_pending_platoon_title) - .setMessage(R.string.discard_pending_platoon_message) - .setNegativeButton(R.string.keep_choosing, null) - .setPositiveButton(R.string.discard, null) - .create() - warning.setOnShowListener { - warning.getButton(AlertDialog.BUTTON_POSITIVE).apply { - useDestructiveActionStyle() - setOnClickListener { + private fun confirmDiscard(activity: Activity, candidate: PendingPlatoonAdmissionStore.Summary) { + Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.discard_pending_platoon_title) + .setMessage(R.string.discard_pending_platoon_message) + .setNegativeButton(R.string.keep_choosing, null) + .setPositiveButton(R.string.discard) { _, _ -> activity.startService( Intent(activity, CaptureVpnService::class.java) .setAction(CaptureVpnService.ACTION_DISCARD_PENDING_PLATOON) .putExtra(CaptureVpnService.EXTRA_PENDING_TOKEN, candidate.token), ) - warning.dismiss() - parent.dismiss() - parent.window?.decorView?.postDelayed( - { showIfNeeded(activity) }, - PROMPT_REFRESH_DELAY_MILLIS, - ) - } - } - } - warning.show() - } - - private fun refreshAfterAdmission(activity: Activity, token: String, attempt: Int = 0) { - activity.window.decorView.postDelayed( - { - if (activity.isFinishing || activity.isDestroyed) return@postDelayed - if (!PendingPlatoonAdmissionStore.contains(token)) { - activity.recreate() - } else if (attempt < MAX_REFRESH_ATTEMPTS) { - refreshAfterAdmission(activity, token, attempt + 1) - } else { - showIfNeeded(activity) - } - }, - PROMPT_REFRESH_DELAY_MILLIS, + }, + destructive = true, ) } - - private fun Activity.serverRegionLabel(region: GameServerRegion): String = getString( - when (region) { - GameServerRegion.MANUAL -> R.string.server_region_manual - GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global - GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china - GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global - GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan - GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea - GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia - }, - ) - - private fun matchWidth() = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - - private const val PROMPT_REFRESH_DELAY_MILLIS = 250L - private const val MAX_REFRESH_ATTEMPTS = 20 } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index bf22426..4d4dd91 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.management.PlatoonSnapshot import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.AccessibleRow import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.BarAction @@ -59,7 +60,6 @@ import java.time.ZoneId /** Platoon roster: search, filter, and sort members; long-press starts selection for export. */ class PlatoonActivity : BaseActivity() { - override val legacyStyling = false private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository @@ -211,7 +211,7 @@ class PlatoonActivity : BaseActivity() { addView(PlatoonProfileSelector.card(this@PlatoonActivity), lp(top = 4)) addView(summary, lp(top = 12)) addView(searchInput, lp(top = 16)) - addView(ModernUi.segmentedControl( + addView(Controls.segmentedControl( context = this@PlatoonActivity, options = Filter.entries.map { it.name to getString(it.label) }, selectedValue = filter.name, @@ -376,6 +376,7 @@ class PlatoonActivity : BaseActivity() { ) val error = text(null, TextRole.CAPTION) { setTextColor(getColor(R.color.destructive_action)) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE visibility = View.GONE } val content = Dialogs.content(this) { @@ -406,7 +407,6 @@ class PlatoonActivity : BaseActivity() { if (problem != null) { error.text = getString(problem) error.visibility = View.VISIBLE - error.announceForAccessibility(error.text) return@setOnClickListener } button.isEnabled = false @@ -450,7 +450,7 @@ class PlatoonActivity : BaseActivity() { isClickable = false isFocusable = false importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - buttonTintList = ModernUi.controlTint(this@PlatoonActivity) + buttonTintList = Controls.controlTint(this@PlatoonActivity) } private val name = text(null, TextRole.SUBTITLE) { maxLines = 1 diff --git a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt index e6a898e..fdddc3d 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt @@ -2,312 +2,202 @@ package dev.gf2log.app import android.app.Activity import android.app.AlertDialog -import android.graphics.Typeface import android.text.Editable -import android.text.InputFilter import android.text.InputType import android.text.TextWatcher -import android.view.Gravity -import android.view.ViewGroup -import android.widget.EditText -import android.widget.ImageButton +import android.view.View import android.widget.LinearLayout import android.widget.RadioButton import android.widget.RadioGroup -import android.widget.ScrollView -import android.widget.TextView import dev.gf2log.app.management.PlatoonClient import dev.gf2log.app.management.PlatoonProfile import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField +import java.util.concurrent.Executors /** Selects or declares the explicit profile that will own an identity-free roster CSV. */ internal object PlatoonCsvImportPrompt { fun show(activity: Activity, onSelected: (PlatoonProfile) -> Unit) { - showSelector( - activity = activity, - initiallySelectedStorageId = PlatoonProfileRegistry(activity).active()?.storageId, - onSelected = onSelected, - ) + showSelector(activity, PlatoonProfileRegistry(activity).active()?.storageId, onSelected) } - private fun showSelector( - activity: Activity, - initiallySelectedStorageId: String?, - onSelected: (PlatoonProfile) -> Unit, - ) { - val registry = PlatoonProfileRegistry(activity) - val profiles = registry.list() - var selected = profiles.firstOrNull { it.storageId == initiallySelectedStorageId } - val choices = RadioGroup(activity).apply { - orientation = RadioGroup.VERTICAL - profiles.forEach { profile -> - addView(RadioButton(context).apply { - id = android.view.View.generateViewId() - text = profileLabel(activity, profile) - textSize = 14f - minimumHeight = activity.dp(48) - isChecked = profile.storageId == selected?.storageId - setOnCheckedChangeListener { _, checked -> - if (checked) selected = profile - } - }, matchWidth()) - } + private fun showSelector(activity: Activity, preselected: String?, onSelected: (PlatoonProfile) -> Unit) { + val profiles = PlatoonProfileRegistry(activity).list() + var selected = profiles.firstOrNull { it.storageId == preselected } + lateinit var dialog: AlertDialog + val choices = radioGroup(activity, profiles.map { "${it.platoonName}\n${Labels.profileIdentity(activity, it)}" }, profiles.indexOf(selected)) { + selected = profiles[it] + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = true } - val add = ImageButton(activity).apply { - setImageResource(R.drawable.ic_add_circle) - contentDescription = activity.getString(R.string.add_new_platoon_profile) - useModernIconStyle() - imageTintList = null - setPadding(activity.dp(10), activity.dp(10), activity.dp(10), activity.dp(10)) - } - val content = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(activity.dp(16), activity.dp(4), activity.dp(16), 0) - addView(choices, matchWidth()) - addView( - add, - LinearLayout.LayoutParams(activity.dp(48), activity.dp(48)).apply { - gravity = Gravity.CENTER_HORIZONTAL - topMargin = activity.dp(if (profiles.isEmpty()) 4 else 8) - }, - ) + val content = Dialogs.content(activity) { + addView(activity.text(activity.getString(R.string.csv_import_choose_destination), TextRole.SECONDARY), activity.lp()) + if (profiles.isEmpty()) { + addView(activity.text(activity.getString(R.string.no_platoon_detected_detail), TextRole.BODY), activity.lp(top = 12)) + } else { + addView(choices, activity.lp(top = 8)) + } + addView(activity.button(activity.getString(R.string.add_new_platoon_profile), Controls.Role.TERTIARY, R.drawable.ic_add_circle) { + showCreate(activity) { created -> + dialog.dismiss() + showSelector(activity, created.storageId, onSelected) + } + }, activity.lp(top = 8)) } - val dialog = AlertDialog.Builder(activity) - .setTitle(R.string.select_platoon) - .setView(ScrollView(activity).apply { addView(content) }) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.select, null) - .create() - dialog.setOnShowListener { - val select = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { + dialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.csv_import_destination_title) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.continue_action, null), + ) { shown -> + shown.getButton(AlertDialog.BUTTON_POSITIVE).apply { isEnabled = selected != null - usePrimaryActionStyle() setOnClickListener { val target = selected ?: return@setOnClickListener - dialog.dismiss() + shown.dismiss() onSelected(target) } } - choices.setOnCheckedChangeListener { _, checkedId -> - val index = (0 until choices.childCount) - .indexOfFirst { choices.getChildAt(it).id == checkedId } - selected = profiles.getOrNull(index) - select.isEnabled = selected != null - } - add.setOnClickListener { - showCreate(activity, registry) { created -> - dialog.dismiss() - showSelector(activity, created.storageId, onSelected) - } - } } - dialog.show() } - private fun showCreate( - activity: Activity, - registry: PlatoonProfileRegistry, - onCreated: (PlatoonProfile) -> Unit, - ) { - var client = PlatoonClient.HAOPLAY + private fun showCreate(activity: Activity, onCreated: (PlatoonProfile) -> Unit) { + val clients = listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER) + var client = clients.first() var region: GameServerRegion? = null - val name = EditText(activity).apply { - hint = activity.getString(R.string.platoon_name) - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES - filters = arrayOf(InputFilter.LengthFilter(PlatoonProfile.MAX_NAME_LENGTH)) - maxLines = 1 - setSingleLine(true) - } - val platoonIdInput = EditText(activity).apply { - hint = activity.getString(R.string.platoon_id) - inputType = InputType.TYPE_CLASS_NUMBER - maxLines = 1 - setSingleLine(true) + val name = LabeledField( + activity, + activity.getString(R.string.platoon_name), + activity.textField(activity.getString(R.string.platoon_name), maxLength = PlatoonProfile.MAX_NAME_LENGTH), + ) + val platoonId = LabeledField( + activity, + activity.getString(R.string.platoon_id), + activity.textField(activity.getString(R.string.platoon_id_hint), inputType = InputType.TYPE_CLASS_NUMBER), + ) + val servers = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL } + val error = activity.text(null, TextRole.CAPTION) { + setTextColor(activity.getColor(R.color.destructive_action)) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + visibility = View.GONE } - val serverChoices = RadioGroup(activity).apply { - orientation = RadioGroup.VERTICAL + lateinit var dialog: AlertDialog + fun validId(): Long? = platoonId.input.text.toString().toLongOrNull()?.takeIf { it in 1L..UInt.MAX_VALUE.toLong() } + fun updateEnabled() { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = + name.input.text.isNotBlank() && validId() != null && region != null } - fun rebuildServerChoices() { + fun rebuildServers() { region = null - serverChoices.removeAllViews() - ClientServerRegionPreferences.allowedFor(client.packageName).forEach { candidate -> - serverChoices.addView(RadioButton(activity).apply { - id = android.view.View.generateViewId() - text = serverRegionLabel(activity, candidate) - textSize = 14f - minimumHeight = activity.dp(48) - setOnCheckedChangeListener { _, checked -> - if (checked) region = candidate - } - }, matchWidth()) - } - } - rebuildServerChoices() - val clients = RadioGroup(activity).apply { - orientation = RadioGroup.HORIZONTAL - listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER).forEach { candidate -> - addView(RadioButton(context).apply { - id = android.view.View.generateViewId() - text = candidate.displayName - textSize = 14f - minimumHeight = activity.dp(48) - isChecked = candidate == client - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - } - setOnCheckedChangeListener { group, checkedId -> - val index = (0 until group.childCount) - .indexOfFirst { group.getChildAt(it).id == checkedId } - client = listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER) - .getOrElse(index) { PlatoonClient.HAOPLAY } - rebuildServerChoices() - } - } - val error = TextView(activity).apply { - textSize = 12f - setTextColor(context.getColor(R.color.destructive_action)) - visibility = android.view.View.GONE - } - val content = LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setPadding(activity.dp(20), activity.dp(4), activity.dp(20), 0) - addView(TextView(context).apply { - text = activity.getString(R.string.new_platoon_entry_message) - textSize = 14f - setTextColor(context.getColor(R.color.text_secondary)) - }, matchWidth()) - addView(fieldLabel(activity, R.string.platoon_client), matchWidth().apply { - topMargin = activity.dp(16) - }) - addView(clients, matchWidth()) - addView(fieldLabel(activity, R.string.server_region), matchWidth().apply { - topMargin = activity.dp(8) - }) - addView(serverChoices, matchWidth()) - addView(fieldLabel(activity, R.string.platoon_name), matchWidth().apply { - topMargin = activity.dp(8) - }) - addView(name, matchWidth()) - addView(fieldLabel(activity, R.string.platoon_id), matchWidth().apply { - topMargin = activity.dp(8) + servers.removeAllViews() + val allowed = ClientServerRegionPreferences.allowedFor(client.packageName) + servers.addView(radioGroup(activity, allowed.map { Labels.region(activity, it) }, -1) { + region = allowed[it] + updateEnabled() }) - addView(platoonIdInput, matchWidth()) - addView(error, matchWidth().apply { topMargin = activity.dp(8) }) } - val dialog = AlertDialog.Builder(activity) - .setTitle(R.string.enter_new_platoon_profile) - .setView(ScrollView(activity).apply { addView(content) }) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.confirm, null) - .create() - dialog.setOnShowListener { - val confirm = dialog.getButton(AlertDialog.BUTTON_POSITIVE).apply { - usePrimaryActionStyle() - } - fun validId(): Long? = platoonIdInput.text.toString().toLongOrNull() - ?.takeIf { it in 1L..UInt.MAX_VALUE.toLong() } - fun updateEnabled() { - confirm.isEnabled = name.text.toString().trim().isNotEmpty() && - validId() != null && - region != null - } + val content = Dialogs.content(activity) { + addView(activity.text(activity.getString(R.string.new_platoon_entry_message), TextRole.SECONDARY), activity.lp()) + addView(activity.text(activity.getString(R.string.platoon_client), TextRole.LABEL), activity.lp(top = 12)) + addView(Controls.segmentedControl( + context = activity, + options = clients.map { it.name to it.displayName }, + selectedValue = client.name, + ) { value -> + client = PlatoonClient.valueOf(value) + rebuildServers() + updateEnabled() + }, activity.lp(top = 4)) + addView(activity.text(activity.getString(R.string.server_region), TextRole.LABEL), activity.lp(top = 12)) + addView(servers, activity.lp()) + addView(name, activity.lp(top = 12)) + addView(platoonId, activity.lp(top = 12)) + addView(error, activity.lp(top = 8)) + } + rebuildServers() + dialog = Dialogs.show( + Dialogs.builder(activity) + .setTitle(R.string.enter_new_platoon_profile) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.add, null), + ) { shown -> val watcher = object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - error.visibility = android.view.View.GONE + error.visibility = View.GONE updateEnabled() } override fun afterTextChanged(s: Editable?) = Unit } - name.addTextChangedListener(watcher) - platoonIdInput.addTextChangedListener(watcher) - clients.setOnCheckedChangeListener { group, checkedId -> - val index = (0 until group.childCount) - .indexOfFirst { group.getChildAt(it).id == checkedId } - client = listOf(PlatoonClient.HAOPLAY, PlatoonClient.DARKWINTER) - .getOrElse(index) { PlatoonClient.HAOPLAY } - rebuildServerChoices() - serverChoices.setOnCheckedChangeListener { _, checkedRegionId -> - val regions = ClientServerRegionPreferences.allowedFor(client.packageName) - val selectedIndex = (0 until serverChoices.childCount) - .indexOfFirst { serverChoices.getChildAt(it).id == checkedRegionId } - region = regions.getOrNull(selectedIndex) - updateEnabled() - } - updateEnabled() - } - serverChoices.setOnCheckedChangeListener { _, checkedId -> - val regions = ClientServerRegionPreferences.allowedFor(client.packageName) - val index = (0 until serverChoices.childCount) - .indexOfFirst { serverChoices.getChildAt(it).id == checkedId } - region = regions.getOrNull(index) - updateEnabled() - } - confirm.setOnClickListener { + name.input.addTextChangedListener(watcher) + platoonId.input.addTextChangedListener(watcher) + shown.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> val selectedRegion = region ?: return@setOnClickListener - val platoonId = validId() ?: return@setOnClickListener - runCatching { - registry.createDeclared( - client = client, - region = selectedRegion, - platoonId = platoonId, - platoonName = name.text.toString(), - ) - }.fold( - onSuccess = { - dialog.dismiss() - onCreated(it) - }, - onFailure = { - error.text = activity.getString(R.string.unable_to_add_platoon_profile) - error.visibility = android.view.View.VISIBLE - }, - ) + val id = validId() ?: return@setOnClickListener + val chosenClient = client + val platoonName = name.input.text.toString() + button.isEnabled = false + profileExecutor.execute { + val result = runCatching { + PlatoonProfileRegistry(activity).createDeclared(chosenClient, selectedRegion, id, platoonName) + } + activity.runOnUiThread { + if (activity.isFinishing || activity.isDestroyed) return@runOnUiThread + button.isEnabled = true + result.fold( + onSuccess = { + shown.dismiss() + onCreated(it) + }, + onFailure = { + error.text = activity.getString(R.string.unable_to_add_platoon_profile) + error.visibility = View.VISIBLE + }, + ) + } + } } updateEnabled() } - dialog.show() } - private fun fieldLabel(activity: Activity, text: Int) = TextView(activity).apply { - setText(text) - textSize = 13f - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - setTextColor(context.getColor(R.color.text_secondary)) + private fun radioGroup( + activity: Activity, + labels: List, + checked: Int, + onChecked: (Int) -> Unit, + ): RadioGroup = RadioGroup(activity).apply { + orientation = RadioGroup.VERTICAL + val ids = labels.mapIndexed { index, label -> + RadioButton(activity).apply { + id = View.generateViewId() + text = label + textSize = 14f + setTextColor(activity.getColor(R.color.text_primary)) + buttonTintList = Controls.controlTint(activity) + minimumHeight = activity.dp(48) + setPadding(activity.dp(8), activity.dp(6), 0, activity.dp(6)) + isChecked = index == checked + }.also { addView(it, RadioGroup.LayoutParams(RadioGroup.LayoutParams.MATCH_PARENT, RadioGroup.LayoutParams.WRAP_CONTENT)) }.id + } + setOnCheckedChangeListener { _, checkedId -> + ids.indexOf(checkedId).takeIf { it >= 0 }?.let(onChecked) + } } - private fun profileLabel(activity: Activity, profile: PlatoonProfile): String = - "${profile.client.displayName} / ${regionCode(profile.serverRegion)} / " + - "${profile.platoonName} / ${profile.platoonId}" - - private fun regionCode(region: GameServerRegion): String = when (region) { - GameServerRegion.MANUAL -> "Manual" - GameServerRegion.DARKWINTER_GLOBAL, GameServerRegion.HAOPLAY_GLOBAL -> "GL" - GameServerRegion.DARKWINTER_CHINA -> "CN" - GameServerRegion.HAOPLAY_JAPAN -> "JP" - GameServerRegion.HAOPLAY_KOREA -> "KR" - GameServerRegion.HAOPLAY_ASIA -> "ASIA" + private val profileExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "GF2ProfileCreate") } - - private fun serverRegionLabel(activity: Activity, region: GameServerRegion): String = - activity.getString( - when (region) { - GameServerRegion.MANUAL -> R.string.server_region_manual - GameServerRegion.DARKWINTER_GLOBAL -> R.string.server_region_darkwinter_global - GameServerRegion.DARKWINTER_CHINA -> R.string.server_region_darkwinter_china - GameServerRegion.HAOPLAY_GLOBAL -> R.string.server_region_haoplay_global - GameServerRegion.HAOPLAY_JAPAN -> R.string.server_region_haoplay_japan - GameServerRegion.HAOPLAY_KOREA -> R.string.server_region_haoplay_korea - GameServerRegion.HAOPLAY_ASIA -> R.string.server_region_haoplay_asia - }, - ) - - private fun matchWidth() = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - - private fun Activity.dp(value: Int): Int = - (value * resources.displayMetrics.density).toInt() } diff --git a/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt b/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt deleted file mode 100644 index 8ce9b23..0000000 --- a/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt +++ /dev/null @@ -1,33 +0,0 @@ -package dev.gf2log.app - -import android.widget.Button - -fun Button.usePrimaryActionStyle() = useActionRole(ModernUi.ControlRole.PRIMARY) - -fun Button.useCaptureActionStyle() = useActionRole(ModernUi.ControlRole.CAPTURE) - -fun Button.useFeatureActionStyle() = useActionRole(ModernUi.ControlRole.FEATURE) - -fun Button.useSecondaryActionStyle() = useActionRole(ModernUi.ControlRole.SECONDARY) - -fun Button.useTertiaryActionStyle() = useActionRole(ModernUi.ControlRole.TERTIARY) - -fun Button.useNavigationActionStyle() = useActionRole(ModernUi.ControlRole.NAVIGATION) - -fun Button.useSelectorActionStyle() = useActionRole(ModernUi.ControlRole.SELECTOR) - -fun Button.useEvidenceActionStyle() = useActionRole(ModernUi.ControlRole.EVIDENCE) - -fun Button.useDestructiveActionStyle() = useActionRole(ModernUi.ControlRole.DESTRUCTIVE) - -fun Button.useDestructiveTextActionStyle() = useActionRole(ModernUi.ControlRole.DESTRUCTIVE_TEXT) - -fun Button.allowCompactMultilineLabel() { - setTag(R.id.gf2_ui_compact_multiline, true) - ModernUi.styleButton(this) -} - -private fun Button.useActionRole(role: ModernUi.ControlRole) { - setTag(R.id.gf2_ui_role, role) - ModernUi.styleButton(this) -} diff --git a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt index 102d96d..b0541aa 100644 --- a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt +++ b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt @@ -13,6 +13,8 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import dev.gf2log.app.management.PlatoonProfileRegistry +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.dp import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -24,24 +26,6 @@ internal object PrimaryNavigation { WEEKLY, } - fun wrap(activity: Activity, content: View, selected: Destination): View = - LinearLayout(activity).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(activity.getColor(R.color.app_background)) - addView(content, LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 0, - 1f, - )) - addView(View(activity).apply { - setBackgroundColor(activity.getColor(R.color.outline)) - }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, activity.dp(1))) - addView(navigationBar(activity, selected), LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - activity.dp(64), - )) - } - /** The bottom bar alone, for screens composed through [dev.gf2log.app.ui.Screen]. */ fun bar(activity: Activity, selected: Destination): View = navigationBar(activity, selected).apply { minimumHeight = activity.dp(64) @@ -119,11 +103,7 @@ internal object PrimaryNavigation { if (destination != Destination.HOME && PlatoonProfileRegistry(activity).active() == null ) { - TransientMessage.show( - activity, - R.string.no_platoon_detected_detail, - android.widget.Toast.LENGTH_LONG, - ) + Feedback.show(activity, R.string.no_platoon_detected_detail, long = true) } else { activity.startActivity( Intent(activity, activityClass).addFlags( diff --git a/app/src/main/java/dev/gf2log/app/SimpleItemSelectedListener.kt b/app/src/main/java/dev/gf2log/app/SimpleItemSelectedListener.kt deleted file mode 100644 index 48f9206..0000000 --- a/app/src/main/java/dev/gf2log/app/SimpleItemSelectedListener.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.gf2log.app - -import android.view.View -import android.widget.AdapterView - -class SimpleItemSelectedListener( - private val selected: () -> Unit, -) : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - selected() - } - - override fun onNothingSelected(parent: AdapterView<*>?) = Unit -} diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index ffbf722..b14fff3 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -31,7 +31,6 @@ import java.time.ZoneId /** Joins, withdrawals, and counter changes between the two latest roster snapshots. */ class SnapshotComparisonActivity : BaseActivity() { - override val legacyStyling = false private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var screen: Screen diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt index 5e30059..172523f 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -13,6 +13,7 @@ import dev.gf2log.app.management.WeeklyOverrideEditing import dev.gf2log.app.management.WeeklyOverrideEditing.Choice import dev.gf2log.app.management.WeeklyReportBuilder import dev.gf2log.app.management.WeeklyShareProjection +import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.LabeledField import dev.gf2log.app.ui.TextRole @@ -102,6 +103,7 @@ internal object WeeklyDialogs { var patrol = patrolInitial val error = activity.text(null, TextRole.CAPTION) { setTextColor(activity.getColor(R.color.destructive_action)) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE visibility = View.GONE } val content = Dialogs.content(activity) { @@ -138,7 +140,6 @@ internal object WeeklyDialogs { if (probe.field == WeeklyOverrideEditing.Field.ATTEMPTS) R.string.invalid_attempts else R.string.invalid_counter, ) error.visibility = View.VISIBLE - error.announceForAccessibility(error.text) return } dialog.dismiss() @@ -178,7 +179,7 @@ internal object WeeklyDialogs { addView(activity.text(activity.getString(label), TextRole.LABEL) { setPadding(activity.dp(4), 0, 0, activity.dp(4)) }, activity.lp()) - addView(ModernUi.segmentedControl( + addView(Controls.segmentedControl( context = activity, options = listOf( Choice.UNSET.name to activity.getString(R.string.choice_unset), diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 34267e9..72f611f 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -37,6 +37,7 @@ import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines +import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.BarAction import dev.gf2log.app.ui.Dialogs @@ -74,7 +75,6 @@ import java.time.ZoneId * one vertical list. Tapping a cell explains its evidence; edit mode turns taps into manual edits. */ class WeeklyReportActivity : BaseActivity() { - override val legacyStyling = false private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var scope: PlatoonStorageScope @@ -341,7 +341,7 @@ class WeeklyReportActivity : BaseActivity() { setTextColor(getColor(R.color.accent_text)) }, lp()) addView( - button(getString(R.string.show_live_table), ModernUi.ControlRole.SECONDARY) { + button(getString(R.string.show_live_table), Controls.Role.SECONDARY) { showLiveTable(report.periodStart) }, lp(WRAP, WRAP, top = 8), @@ -401,7 +401,7 @@ class WeeklyReportActivity : BaseActivity() { }, lp()) if (report.periodStart != next.currentWeekStart && !editing) { addView( - button(getString(R.string.go_to_current_week), ModernUi.ControlRole.TERTIARY, R.drawable.ic_today) { + button(getString(R.string.go_to_current_week), Controls.Role.TERTIARY, R.drawable.ic_today) { reportState.selectDate(PlatoonPeriods.gameDay(Instant.now(), next.zone)) requestRender() }, @@ -552,7 +552,7 @@ class WeeklyReportActivity : BaseActivity() { addView(text(getString(R.string.add_note), TextRole.SUBTITLE), lp()) addView(noteDayButton, lp(top = 8)) addView(noteInput, lp(top = 8)) - addView(button(getString(R.string.add_note), ModernUi.ControlRole.PRIMARY) { addNote(report) }, lp(top = 8)) + addView(button(getString(R.string.add_note), Controls.Role.PRIMARY) { addNote(report) }, lp(top = 8)) } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt index c55e5a3..21f746a 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt @@ -8,6 +8,7 @@ import android.widget.LinearLayout import androidx.activity.OnBackPressedCallback import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines +import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BarAction import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Feedback @@ -25,7 +26,6 @@ import dev.gf2log.app.ui.textField /** Per-Platoon cutlines that highlight weekly cells which are, or may remain, below a target. */ class WeeklySettingsActivity : BaseActivity() { - override val legacyStyling = false private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var preferences: WeeklyCutlinePreferences @@ -66,7 +66,7 @@ class WeeklySettingsActivity : BaseActivity() { threshold(R.id.cutline_login_days, R.string.weekly_login_cutline, initial.weeklyLoginDays?.toLong(), WEEK_DAYS) threshold(R.id.cutline_patrol_days, R.string.weekly_patrol_cutline, initial.weeklyPatrolDays?.toLong(), WEEK_DAYS) }, lp()) - addView(button(getString(R.string.save_settings), ModernUi.ControlRole.PRIMARY) { save() }, lp(top = 16)) + addView(button(getString(R.string.save_settings), Controls.Role.PRIMARY) { save() }, lp(top = 16)) } screen.setBody(Screen.scrollingBody(this, content)) } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt index b0fd535..1bf5477 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryActivity.kt @@ -19,7 +19,6 @@ import java.time.LocalDate /** Lists the bounded immutable revisions recorded for one weekly table. */ class WeeklyTableHistoryActivity : BaseActivity() { - override val legacyStyling = false private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt index fae83f0..b781167 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt @@ -12,6 +12,7 @@ import dev.gf2log.app.management.WeeklyEvidenceAnalyzer import dev.gf2log.app.management.WeeklyReportBuilder import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences +import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Feedback @@ -31,7 +32,6 @@ import java.time.LocalDate /** Read-only view of one recorded weekly table with the explicit restore action. */ class WeeklyTableHistoryPreviewActivity : BaseActivity() { - override val legacyStyling = false private lateinit var profileBinding: ActivePlatoonScopeBinding private lateinit var repository: PlatoonRepository @@ -85,7 +85,7 @@ class WeeklyTableHistoryPreviewActivity : BaseActivity() { addView(text(getString(R.string.weekly_table_history_preview_notice), TextRole.SECONDARY) { setTextColor(getColor(R.color.accent_text)) }, lp()) - restoreButton = button(getString(R.string.weekly_table_history_restore), ModernUi.ControlRole.PRIMARY) { + restoreButton = button(getString(R.string.weekly_table_history_restore), Controls.Role.PRIMARY) { confirmRestore() } addView(restoreButton, lp(top = 8)) diff --git a/app/src/main/java/dev/gf2log/app/ui/Controls.kt b/app/src/main/java/dev/gf2log/app/ui/Controls.kt new file mode 100644 index 0000000..ba5f8e0 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/Controls.kt @@ -0,0 +1,241 @@ +package dev.gf2log.app.ui + +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.Button +import android.widget.FrameLayout +import android.widget.RadioButton +import android.widget.RadioGroup +import androidx.annotation.ColorRes +import dev.gf2log.app.R + +/** Semantic control styling from docs/UI_DESIGN_SYSTEM.md; screens choose roles, never colors. */ +internal object Controls { + enum class Role { + /** The single commitment action of a screen or panel. */ + PRIMARY, + + /** Starts continuous capture; green readiness treatment. */ + CAPTURE, + SECONDARY, + + /** Low-emphasis inline action such as Select or Show all. */ + TERTIARY, + DESTRUCTIVE, + DESTRUCTIVE_TEXT, + } + + private data class Palette( + @param:ColorRes val fill: Int, + @param:ColorRes val pressed: Int, + @param:ColorRes val stroke: Int, + @param:ColorRes val text: Int, + ) + + private fun palette(role: Role): Palette = when (role) { + Role.PRIMARY -> Palette( + R.color.primary_action_background, + R.color.primary_action_pressed, + android.R.color.transparent, + R.color.primary_action_foreground, + ) + Role.CAPTURE -> Palette(R.color.success_surface, R.color.success_pressed, R.color.success_text, R.color.success_text) + Role.SECONDARY -> Palette(R.color.surface_variant, R.color.surface_pressed, android.R.color.transparent, R.color.text_primary) + Role.TERTIARY -> Palette(android.R.color.transparent, R.color.surface_pressed, android.R.color.transparent, R.color.accent_text) + Role.DESTRUCTIVE -> Palette( + R.color.destructive_action_background, + R.color.destructive_action_pressed, + android.R.color.transparent, + R.color.destructive_action_foreground, + ) + Role.DESTRUCTIVE_TEXT -> Palette( + android.R.color.transparent, + R.color.surface_pressed, + android.R.color.transparent, + R.color.destructive_action, + ) + } + + /** A 48dp touch target around a 40dp visible surface with explicit disabled state. */ + fun style(button: Button, role: Role) { + val context = button.context + val colors = palette(role) + button.isAllCaps = false + button.elevation = 0f + button.stateListAnimator = null + button.minHeight = context.dp(48) + button.minWidth = context.dp(48) + button.textSize = 14f + button.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + button.maxLines = 2 + button.ellipsize = TextUtils.TruncateAt.END + button.gravity = Gravity.CENTER + button.setPaddingRelative(context.dp(16), 0, context.dp(16), 0) + button.setTextColor( + ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), + intArrayOf(context.getColor(R.color.text_secondary), context.getColor(colors.text)), + ), + ) + button.backgroundTintList = null + button.background = InsetDrawable( + StateListDrawable().apply { + val disabledFill = if (colors.fill == android.R.color.transparent) colors.fill else R.color.surface_variant + addState(intArrayOf(-android.R.attr.state_enabled), shape(context, disabledFill, android.R.color.transparent)) + addState(intArrayOf(android.R.attr.state_pressed), shape(context, colors.pressed, colors.stroke)) + addState(intArrayOf(), shape(context, colors.fill, colors.stroke)) + }, + 0, + context.dp(4), + 0, + context.dp(4), + ) + button.refreshDrawableState() + button.jumpDrawablesToCurrentState() + } + + private fun shape(context: Context, @ColorRes fill: Int, @ColorRes stroke: Int): Drawable = GradientDrawable().apply { + setColor(context.getColor(fill)) + cornerRadius = context.dpf(12f) + if (stroke != android.R.color.transparent) setStroke(context.dp(1), context.getColor(stroke)) + } + + fun controlTint(context: Context) = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + intArrayOf( + context.getColor(R.color.outline_strong), + context.getColor(R.color.accent), + context.getColor(R.color.outline_strong), + ), + ) + + fun switchThumbTint(context: Context) = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + intArrayOf( + context.getColor(R.color.surface_variant), + context.getColor(R.color.accent), + context.getColor(R.color.surface), + ), + ) + + fun switchTrackTint(context: Context) = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + intArrayOf( + context.getColor(R.color.outline), + context.getColor(R.color.accent_surface_pressed), + context.getColor(R.color.outline_strong), + ), + ) + + /** + * Mutually exclusive choices on one 56dp rail: a 48dp accent thumb glides between equal-width + * native radio buttons, and motion is skipped when system animations are off. + */ + fun segmentedControl( + context: Context, + options: List>, + selectedValue: String, + onSelected: (String) -> Unit, + ): View { + require(options.isNotEmpty()) { "A segmented control requires at least one option" } + val segmentHeight = context.dp(48) + val selection = View(context).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + background = GradientDrawable().apply { + setColor(context.getColor(R.color.accent_surface)) + cornerRadius = context.dpf(12f) + } + } + val choices = RadioGroup(context).apply { orientation = RadioGroup.HORIZONTAL } + val rail = FrameLayout(context).apply { + val inset = context.dp(4) + setPadding(inset, inset, inset, inset) + background = GradientDrawable().apply { + setColor(context.getColor(R.color.surface_variant)) + cornerRadius = context.dpf(16f) + } + addView(selection, FrameLayout.LayoutParams(0, segmentHeight)) + addView(choices, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, segmentHeight)) + } + var currentValue = selectedValue + + fun moveSelection(index: Int, animated: Boolean, finished: (() -> Unit)? = null) { + val segment = choices.getChildAt(index) + if (segment == null || segment.width <= 0) { + rail.post { moveSelection(index, animated, finished) } + return + } + selection.layoutParams = (selection.layoutParams as FrameLayout.LayoutParams).apply { width = segment.width } + val target = segment.left.toFloat() + selection.animate().cancel() + if (animated && ValueAnimator.areAnimatorsEnabled()) { + selection.animate().translationX(target).setDuration(160L).withEndAction { finished?.invoke() }.start() + } else { + selection.translationX = target + finished?.invoke() + } + } + + val textColors = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(context.getColor(R.color.accent_text), context.getColor(R.color.text_primary)), + ) + options.forEachIndexed { index, (value, label) -> + choices.addView(RadioButton(context).apply { + id = View.generateViewId() + text = label + textSize = 14f + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + gravity = Gravity.CENTER + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + buttonDrawable = null + background = RippleDrawable( + ColorStateList.valueOf(context.getColor(R.color.accent_surface_pressed)), + null, + GradientDrawable().apply { + setColor(context.getColor(android.R.color.white)) + cornerRadius = context.dpf(12f) + }, + ) + setTextColor(textColors) + isChecked = value == selectedValue + minHeight = segmentHeight + minWidth = context.dp(48) + setPadding(context.dp(4), 0, context.dp(4), 0) + setOnClickListener { + if (value == currentValue) return@setOnClickListener + currentValue = value + moveSelection(index, animated = true) { onSelected(value) } + } + }, RadioGroup.LayoutParams(0, segmentHeight, 1f)) + } + rail.addOnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> + if (right - left == oldRight - oldLeft) return@addOnLayoutChangeListener + moveSelection(options.indexOfFirst { it.first == currentValue }.coerceAtLeast(0), animated = false) + } + return rail + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Screen.kt b/app/src/main/java/dev/gf2log/app/ui/Screen.kt index 3e1943b..57814ef 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Screen.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Screen.kt @@ -73,7 +73,7 @@ internal class Screen( val base = (padded.getTag(R.id.gf2_base_bottom_padding) as? Int) ?: padded.paddingBottom.also { padded.setTag(R.id.gf2_base_bottom_padding, it) } padded.setPadding(padded.paddingLeft, padded.paddingTop, padded.paddingRight, base + bottomInset) - if (padded is ViewGroup) padded.clipToPadding = false + padded.clipToPadding = false } companion object { 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 ceea756..afc8d4d 100644 --- a/app/src/main/java/dev/gf2log/app/ui/StateViews.kt +++ b/app/src/main/java/dev/gf2log/app/ui/StateViews.kt @@ -5,7 +5,6 @@ import android.view.Gravity import android.view.View import android.widget.ProgressBar import androidx.annotation.DrawableRes -import dev.gf2log.app.ModernUi import dev.gf2log.app.R /** Loading, empty, and failure states share one centered composition on every screen. */ @@ -48,7 +47,7 @@ internal object StateViews { } if (actionLabel != null && onAction != null) { addView( - context.button(actionLabel, ModernUi.ControlRole.SECONDARY) { onAction() }, + context.button(actionLabel, Controls.Role.SECONDARY) { onAction() }, context.lp(WRAP, WRAP, top = 16), ) } 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 0bcb787..74c0dc0 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Views.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -22,7 +22,6 @@ import android.widget.TextView import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.IdRes -import dev.gf2log.app.ModernUi import dev.gf2log.app.R const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT @@ -120,13 +119,12 @@ internal fun View.accessibilityHeading() { internal fun Context.button( label: CharSequence, - role: ModernUi.ControlRole = ModernUi.ControlRole.SECONDARY, + role: Controls.Role = Controls.Role.SECONDARY, @DrawableRes icon: Int? = null, onClick: (View) -> Unit, ): Button = Button(this).apply { text = label - setTag(R.id.gf2_ui_role, role) - ModernUi.styleButton(this) + Controls.style(this, role) icon?.let { drawable -> val image = requireNotNull(getDrawable(drawable)).mutate() image.setTint(currentTextColor) @@ -195,6 +193,7 @@ internal class LabeledField( ) : LinearLayout(context) { private val message = context.text(supporting, TextRole.CAPTION) { setPadding(context.dp(4), context.dp(4), context.dp(4), 0) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE visibility = if (supporting.isNullOrBlank()) View.GONE else View.VISIBLE } @@ -214,7 +213,6 @@ internal class LabeledField( context.getColor(if (error == null) R.color.text_secondary else R.color.destructive_action), ) message.visibility = if (message.text.isNullOrBlank()) View.GONE else View.VISIBLE - if (error != null) announceForAccessibility(error) } } @@ -227,7 +225,7 @@ internal fun Context.checkBox( isChecked = checked textSize = 14f setTextColor(getColor(R.color.text_primary)) - buttonTintList = ModernUi.controlTint(this@checkBox) + buttonTintList = Controls.controlTint(this@checkBox) minimumHeight = dp(48) setOnCheckedChangeListener { _, value -> onChanged(value) } } @@ -244,8 +242,8 @@ internal fun Context.switchRow( val toggle = Switch(this).apply { isChecked = checked isEnabled = enabled - thumbTintList = ModernUi.switchThumbTint(this@switchRow) - trackTintList = ModernUi.switchTrackTint(this@switchRow) + thumbTintList = Controls.switchThumbTint(this@switchRow) + trackTintList = Controls.switchTrackTint(this@switchRow) contentDescription = listOfNotNull(title, badge, detail).joinToString(". ") setOnCheckedChangeListener { _, value -> onChanged(value) } } @@ -268,7 +266,8 @@ internal fun Context.switchRow( addView(text(it, TextRole.CAPTION) { setPadding(0, dp(2), 0, 0) }, lp()) } }, lp(width = 0, weight = 1f)) - addView(toggle, lp(width = WRAP, start = 12)) + // A permanently required option shows its badge instead of an inert switch. + if (enabled || badge == null) addView(toggle, lp(width = WRAP, start = 12)) } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 23420fc..5fc905e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -16,7 +16,7 @@ 주간 전체 보기 설치된 HaoPlay 및 Darkwinter 클라이언트의 프로토콜을 기기에서 캡처합니다. 원본 트래픽은 저장하지 않습니다. - 캡처 준비 + 계속 캡처 한 번만 캡처 %1$s 서클 식별 정보\n%2$s 서클 명단\n%3$s 활동 근거\n%4$s 가입/탈퇴 변경 중지 @@ -72,7 +72,7 @@ 원본 CSV 전송은 설정된 Discord 웹훅으로 전송됩니다. 패킷에 담겨 있는 여러 정보를 통해 멤버 및 주간 테이블을 완성해 나갈 수 있습니다. 백업 - 백업 불러오기 + 백업 복원 전체 백업에 포함된 서클의 모든 관리 데이터와 앱 설정을 복원합니다. 다른 서클은 변경하지 않습니다. 전체 정보 백업하기 앱 설정과 선택한 서클의 가입 이력 및 주간표를 포함한 모든 관리 데이터를 저장합니다. @@ -273,8 +273,8 @@ 선택 기록 저장 보관 패킷 (최대 %1$d개) 선택 보관 기록 삭제 - 최근 서클 CSV 내보내기 - 서클 CSV 파일 가져오기 + 최신 명단 CSV 내보내기 + 명단 CSV 가져오기 CSV 가져오기 완료: 보관 %1$d, 현재 %2$d, 과거 %3$d, 건너뜀 %4$d 선택한 서클 CSV 파일을 가져오지 못했습니다 아직 캡처한 패킷이 없습니다 @@ -590,4 +590,76 @@ 일본 한국 아시아 + 선택한 서클 + 버전 + 백업 및 복원 + 설정에서 서클 데이터를 내보내거나 복원 + 백업을 저장하는 중… + 백업을 복원하는 중… + Android가 VPN을 시작하기를 기다리는 중입니다. + 캡처 중에 게임의 서클 › 멤버와 동향 화면을 여세요. + 수집됨 + 변경 + 서클 활동 + 서클 정보 + 멤버 명단 + 동향 + 파일 선택 + 설치됨 + 미설치 + 복사했습니다 + 복사 + 명단 CSV에는 서클 정보가 없습니다. 파일을 넣을 서클을 직접 선택하세요. + 가져올 서클 + 어느 서클로 가져올까요? + 21917 데이터로는 대상을 검증할 수 없습니다. 올바른 서클인지 확인하세요. + 아직 아무것도 바뀌지 않았습니다. 가져오기 전에 자동 체크포인트를 만들어 되돌릴 수 있습니다. + 이미 있음 + 유효한 파일 + 현재보다 과거 데이터 + 가입 추정 + 파일 속 멤버 + 이름 차이 + 새 UID + 캡처 기간 + 전체 크기 + 탈퇴 추정 + 패킷을 삭제할까요? + 원본 CSV를 내 Discord 채널로 전송 + 최신 명단을 스프레드시트용으로 저장 + 선택한 서클의 멤버, 이력, 주간 표 + 가져오기 + 먼저 미리 보고, 자동 체크포인트로 되돌릴 수 있음 + %1$d KiB + 지난 캡처의 트래픽·파서 통계 + 패킷을 여는 중… + 서클이 감지되면 패킷 기록이 표시됩니다. + 패킷 기록 + 이 패킷은 더 이상 없습니다. + 원본 + 표 + 숫자 서클 ID + 초기화 시각을 바꾸고 주간 기록을 다시 만드는 중… + 최근 %1$d/%2$d + 필수 + 백업에 들어 있는 서클만 교체 + 복원하면 백업에 들어 있는 서클의 데이터를 교체하고 그 서클을 선택합니다. 전체 백업이면 앱 설정도 교체됩니다. 다른 서클은 바뀌지 않습니다. 캡처는 중지해 두세요. + 명단 CSV를 내보냈습니다 + 보관 + 보관 %1$d/%2$d + 보내기 + 설정 + 정보 + 캡처 + 각 클라이언트에서 새 서클을 처음 감지할 때 이 서버를 사용합니다. + 화면 + 서클 + 모두 보기 (%1$d) + 시작 안내 다시 보기 + 되돌리기 + 마지막 가져오기 직전 체크포인트로 복원 + 대기 중 + + 패킷 %1$d개를 삭제할까요? 되돌릴 수 없습니다. + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 84863a7..3ca9c5a 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,7 +1,5 @@ - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be69961..6f8ba43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,8 +16,8 @@ Weekly View all On-device protocol capture for installed HaoPlay and Darkwinter clients. Raw traffic is not stored. - Prepare capture - One-time Capture + Continuous + One-time capture %1$s Platoon identity\n%2$s Platoon roster\n%3$s Activity evidence\n%4$s Membership updates Stop Open payload options @@ -245,14 +245,14 @@ Required and always enabled. %1$s %1$s, payload type %2$s unknown - PLATOON MEMBERS - PLATOON PROFILE - PLATOON ACTIVITY - PLATOON UPDATES - WEAPONS - WEAPON MODS - PUBLIC SKILL ITEMS - FORMATIONS + Platoon members + Platoon profile + Platoon activity + Platoon updates + Weapons + Weapon mods + Public skill items + Formations PLATOON ACTIVITY UPDATES @@ -273,8 +273,8 @@ Save selected history Saved packets (up to %1$d) Delete selected saved history - Export latest Platoon CSV - Import Platoon CSV files + Export latest roster CSV + Import roster CSV files CSV import complete: %1$d retained, %2$d current, %3$d historical, %4$d skipped The selected Platoon CSV files could not be imported No parsed packets captured yet @@ -597,4 +597,77 @@ Join: %3$s · Withdraw: %4$s Japan South Korea Asia + Active Platoon + Version + Backup and restore + Export or restore Platoon data in Settings + Saving backup… + Restoring backup… + Waiting for Android to start the VPN. + Open the game\'s Platoon › Members and Updates pages while capture runs. + captured + Change + Platoon activity + Platoon identity + Member roster + Membership updates + Choose file + Installed + Not installed + Copied + Copy + Roster CSV files do not identify their Platoon. Choose where these files belong. + Destination + Import into which Platoon? + Payload 21917 cannot verify this destination. Make sure it is the right Platoon. + Nothing has changed yet. Importing creates an automatic checkpoint first, so you can undo it. + Already imported + Valid files + Older than current data + Possible joins + Members in files + Name differences + New UIDs + Capture range + Total size + Possible withdrawals + Delete packets? + Send an original CSV to your own Discord channel + Spreadsheet-safe copy of the newest roster + Members, history, and weekly tables of the selected Platoon + Import + Preview first; an automatic checkpoint allows undo + %1$d KiB + Traffic and parser counters from the last session + Opening packet… + Packet history appears after a Platoon is detected. + Packet history + This packet is no longer available. + Raw text + Table + Numeric Platoon ID + Updating the reset time and rebuilding weekly history… + Recent %1$d/%2$d + Required + Replaces only the Platoon inside the backup + Restoring replaces the data of the Platoon inside the backup and then selects it. A complete backup also replaces app settings. Other Platoons are unchanged. Keep capture stopped. + Roster CSV exported + Keep + Saved %1$d/%2$d + Send + Set up + About + Capture + The server is used when a new Platoon is first detected on each client. + Display + Platoon + Show all (%1$d) + Show the welcome guide + Undo + Restore the checkpoint taken before the last import + waiting + + Delete %1$d packet? This cannot be undone. + Delete %1$d packets? This cannot be undone. + From f0c7493bfafd9ffb208248cc513a0de42831b8aa Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:30:34 +0900 Subject: [PATCH 026/125] refactor: describe every weekly cutline key in one field table --- .../UserSettingsPreferencesIntegrationTest.kt | 24 ++++ .../gf2log/app/settings/AppBackupSettings.kt | 71 +++--------- .../app/settings/UserSettingsPreferences.kt | 46 +------- .../app/settings/WeeklyCutlineFields.kt | 105 ++++++++++++++++++ .../app/settings/WeeklyCutlinePreferences.kt | 63 ++--------- .../app/settings/WeeklyCutlineFieldTest.kt | 70 ++++++++++++ 6 files changed, 227 insertions(+), 152 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/settings/WeeklyCutlineFields.kt create mode 100644 app/src/test/java/dev/gf2log/app/settings/WeeklyCutlineFieldTest.kt diff --git a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt index 0f1c74d..29333c5 100644 --- a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt @@ -56,6 +56,29 @@ class UserSettingsPreferencesIntegrationTest { ) } + @Test + fun cutlinesWrittenUnderTheExistingKeysRemainReadable() { + context.getSharedPreferences("user_settings", Context.MODE_PRIVATE).edit() + .putInt("schema_version", 4) + .putLong("cutline_daily_merit", 90L) + .putInt("cutline_weekly_gunsmoke_attempts", 21) + .commit() + val storageId = "0".repeat(32) + context.getSharedPreferences("platoon_weekly_cutlines", Context.MODE_PRIVATE).edit() + .putLong("$storageId.weekly_score", 70_000L) + .putInt("$storageId.weekly_patrol", 5) + .commit() + + assertEquals( + WeeklyCutlines(dailyMerit = 90L, weeklyGunsmokeAttempts = 21), + UserSettingsPreferences.weeklyCutlines(context), + ) + val scoped = WeeklyCutlinePreferences(context, storageId) + assertEquals(WeeklyCutlines(weeklyGunsmokeScore = 70_000L, weeklyPatrolDays = 5), scoped.read()) + scoped.clear() + assertEquals(WeeklyCutlines(), scoped.read()) + } + private fun clearSettings() { listOf( "user_settings", @@ -64,6 +87,7 @@ class UserSettingsPreferencesIntegrationTest { "payload_history_options", "weekly_member_order", "weekly_cutlines", + "platoon_weekly_cutlines", ".app.MainActivity", ).forEach { name -> context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit() diff --git a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt index 5d11e3a..1b93d69 100644 --- a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt +++ b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt @@ -34,14 +34,6 @@ object AppBackupSettingsCodec { private const val KEY_DETAILED_NOTIFICATIONS = "detailedNotifications" private const val KEY_TARGET_PACKAGE = "targetPackage" private const val KEY_MEMBER_ORDER = "memberOrder" - private const val KEY_DAILY_MERIT = "cutline.dailyMerit" - private const val KEY_DAILY_SCORE = "cutline.dailyGunsmokeScore" - private const val KEY_DAILY_ATTEMPTS = "cutline.dailyGunsmokeAttempts" - private const val KEY_WEEKLY_MERIT = "cutline.weeklyMerit" - private const val KEY_WEEKLY_SCORE = "cutline.weeklyGunsmokeScore" - private const val KEY_WEEKLY_ATTEMPTS = "cutline.weeklyGunsmokeAttempts" - private const val KEY_WEEKLY_LOGIN_DAYS = "cutline.weeklyLoginDays" - private const val KEY_WEEKLY_PATROL_DAYS = "cutline.weeklyPatrolDays" fun encode(settings: AppBackupSettings): ByteArray { validate(settings) @@ -55,14 +47,9 @@ object AppBackupSettingsCodec { setProperty(KEY_DETAILED_NOTIFICATIONS, settings.detailedNotifications.toString()) setProperty(KEY_TARGET_PACKAGE, settings.targetPackage) setProperty(KEY_MEMBER_ORDER, settings.memberOrder.joinToString(",")) - setProperty(KEY_DAILY_MERIT, settings.weeklyCutlines.dailyMerit.encoded()) - setProperty(KEY_DAILY_SCORE, settings.weeklyCutlines.dailyGunsmokeScore.encoded()) - setProperty(KEY_DAILY_ATTEMPTS, settings.weeklyCutlines.dailyGunsmokeAttempts.encoded()) - setProperty(KEY_WEEKLY_MERIT, settings.weeklyCutlines.weeklyMerit.encoded()) - setProperty(KEY_WEEKLY_SCORE, settings.weeklyCutlines.weeklyGunsmokeScore.encoded()) - setProperty(KEY_WEEKLY_ATTEMPTS, settings.weeklyCutlines.weeklyGunsmokeAttempts.encoded()) - setProperty(KEY_WEEKLY_LOGIN_DAYS, settings.weeklyCutlines.weeklyLoginDays.encoded()) - setProperty(KEY_WEEKLY_PATROL_DAYS, settings.weeklyCutlines.weeklyPatrolDays.encoded()) + WeeklyCutlineField.entries.forEach { field -> + setProperty(field.backupKey, field.valueIn(settings.weeklyCutlines)?.toString() ?: NONE) + } settings.payloadHistory.toSortedMap().forEach { (payloadType, enabled) -> setProperty(payloadKey(payloadType), enabled.toString()) } @@ -120,16 +107,13 @@ object AppBackupSettingsCodec { } }, memberOrder = properties.memberOrder(), - weeklyCutlines = WeeklyCutlines( - dailyMerit = properties.optionalLong(KEY_DAILY_MERIT), - dailyGunsmokeScore = properties.optionalLong(KEY_DAILY_SCORE), - dailyGunsmokeAttempts = properties.optionalInt(KEY_DAILY_ATTEMPTS), - weeklyMerit = properties.optionalLong(KEY_WEEKLY_MERIT), - weeklyGunsmokeScore = properties.optionalLong(KEY_WEEKLY_SCORE), - weeklyGunsmokeAttempts = properties.optionalInt(KEY_WEEKLY_ATTEMPTS), - weeklyLoginDays = properties.optionalInt(KEY_WEEKLY_LOGIN_DAYS), - weeklyPatrolDays = properties.optionalInt(KEY_WEEKLY_PATROL_DAYS), - ), + weeklyCutlines = WeeklyCutlineField.cutlinesOf { field -> + if (field.storedAsInt) { + properties.optionalInt(field.backupKey)?.toLong() + } else { + properties.optionalLong(field.backupKey) + } + }, ) validate(settings) return settings @@ -162,15 +146,11 @@ object AppBackupSettingsCodec { require(settings.memberOrder.distinct().size == settings.memberOrder.size) { "Member order contains duplicate UIDs" } - settings.weeklyCutlines.run { - requireNonNegative(dailyMerit, KEY_DAILY_MERIT) - requireNonNegative(dailyGunsmokeScore, KEY_DAILY_SCORE) - requireInRange(dailyGunsmokeAttempts, 0..3, KEY_DAILY_ATTEMPTS) - requireNonNegative(weeklyMerit, KEY_WEEKLY_MERIT) - requireNonNegative(weeklyGunsmokeScore, KEY_WEEKLY_SCORE) - requireInRange(weeklyGunsmokeAttempts, 0..21, KEY_WEEKLY_ATTEMPTS) - requireInRange(weeklyLoginDays, 0..7, KEY_WEEKLY_LOGIN_DAYS) - requireInRange(weeklyPatrolDays, 0..7, KEY_WEEKLY_PATROL_DAYS) + WeeklyCutlineField.entries.forEach { field -> + val value = field.valueIn(settings.weeklyCutlines) + require(value == null || value in field.range) { + "Backup setting ${field.backupKey} is out of range" + } } } @@ -222,17 +202,6 @@ object AppBackupSettingsCodec { } } - private fun Long?.encoded() = this?.toString() ?: NONE - private fun Int?.encoded() = this?.toString() ?: NONE - - private fun requireNonNegative(value: Long?, key: String) { - require(value == null || value >= 0L) { "Backup setting $key is out of range" } - } - - private fun requireInRange(value: Int?, range: IntRange, key: String) { - require(value == null || value in range) { "Backup setting $key is out of range" } - } - private val PACKAGE_NAME = Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+") private val BASE_KEYS = setOf( KEY_SCHEMA_VERSION, @@ -244,15 +213,7 @@ object AppBackupSettingsCodec { KEY_DETAILED_NOTIFICATIONS, KEY_TARGET_PACKAGE, KEY_MEMBER_ORDER, - KEY_DAILY_MERIT, - KEY_DAILY_SCORE, - KEY_DAILY_ATTEMPTS, - KEY_WEEKLY_MERIT, - KEY_WEEKLY_SCORE, - KEY_WEEKLY_ATTEMPTS, - KEY_WEEKLY_LOGIN_DAYS, - KEY_WEEKLY_PATROL_DAYS, - ) + ) + WeeklyCutlineField.entries.map(WeeklyCutlineField::backupKey) private val LEGACY_BASE_KEYS = BASE_KEYS - setOf( KEY_THEME_MODE, 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 b1fcc26..ad59e55 100644 --- a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt @@ -20,14 +20,6 @@ internal object UserSettingsPreferences { private const val KEY_DETAILED_NOTIFICATIONS = "detailed_notifications" private const val KEY_TARGET_PACKAGE = "target_package" private const val KEY_MEMBER_ORDER = "member_order" - private const val KEY_DAILY_MERIT = "cutline_daily_merit" - private const val KEY_DAILY_SCORE = "cutline_daily_gunsmoke_score" - private const val KEY_DAILY_ATTEMPTS = "cutline_daily_gunsmoke_attempts" - private const val KEY_WEEKLY_MERIT = "cutline_weekly_merit" - private const val KEY_WEEKLY_SCORE = "cutline_weekly_gunsmoke_score" - private const val KEY_WEEKLY_ATTEMPTS = "cutline_weekly_gunsmoke_attempts" - private const val KEY_WEEKLY_LOGIN_DAYS = "cutline_weekly_login_days" - private const val KEY_WEEKLY_PATROL_DAYS = "cutline_weekly_patrol_days" private val lock = Any() @@ -131,7 +123,7 @@ internal object UserSettingsPreferences { } fun setWeeklyCutlines(context: Context, cutlines: WeeklyCutlines) = edit(context) { - putCutlines(cutlines) + putWeeklyCutlines(cutlines, WeeklyCutlineField::settingsKey) } fun targetPackage(context: Context): String = synchronized(lock) { @@ -290,16 +282,8 @@ internal object UserSettingsPreferences { ) } - private fun readCutlines(preferences: SharedPreferences) = WeeklyCutlines( - dailyMerit = preferences.optionalLong(KEY_DAILY_MERIT), - dailyGunsmokeScore = preferences.optionalLong(KEY_DAILY_SCORE), - dailyGunsmokeAttempts = preferences.optionalInt(KEY_DAILY_ATTEMPTS), - weeklyMerit = preferences.optionalLong(KEY_WEEKLY_MERIT), - weeklyGunsmokeScore = preferences.optionalLong(KEY_WEEKLY_SCORE), - weeklyGunsmokeAttempts = preferences.optionalInt(KEY_WEEKLY_ATTEMPTS), - weeklyLoginDays = preferences.optionalInt(KEY_WEEKLY_LOGIN_DAYS), - weeklyPatrolDays = preferences.optionalInt(KEY_WEEKLY_PATROL_DAYS), - ) + private fun readCutlines(preferences: SharedPreferences) = + preferences.readWeeklyCutlines(WeeklyCutlineField::settingsKey) private fun writeSettings( editor: SharedPreferences.Editor, @@ -319,29 +303,7 @@ internal object UserSettingsPreferences { target.putBoolean(payloadKey(payloadType), enabled) } } - .putCutlines(settings.weeklyCutlines) - - private fun SharedPreferences.Editor.putCutlines(cutlines: WeeklyCutlines) = - putOptionalLong(KEY_DAILY_MERIT, cutlines.dailyMerit) - .putOptionalLong(KEY_DAILY_SCORE, cutlines.dailyGunsmokeScore) - .putOptionalInt(KEY_DAILY_ATTEMPTS, cutlines.dailyGunsmokeAttempts) - .putOptionalLong(KEY_WEEKLY_MERIT, cutlines.weeklyMerit) - .putOptionalLong(KEY_WEEKLY_SCORE, cutlines.weeklyGunsmokeScore) - .putOptionalInt(KEY_WEEKLY_ATTEMPTS, cutlines.weeklyGunsmokeAttempts) - .putOptionalInt(KEY_WEEKLY_LOGIN_DAYS, cutlines.weeklyLoginDays) - .putOptionalInt(KEY_WEEKLY_PATROL_DAYS, cutlines.weeklyPatrolDays) - - private fun SharedPreferences.Editor.putOptionalLong(key: String, value: Long?) = - if (value == null) remove(key) else putLong(key, value) - - private fun SharedPreferences.Editor.putOptionalInt(key: String, value: Int?) = - if (value == null) remove(key) else putInt(key, value) - - private fun SharedPreferences.optionalLong(key: String): Long? = - if (contains(key)) getLong(key, 0L) else null - - private fun SharedPreferences.optionalInt(key: String): Int? = - if (contains(key)) getInt(key, 0) else null + .putWeeklyCutlines(settings.weeklyCutlines, WeeklyCutlineField::settingsKey) private fun SharedPreferences.safeString(key: String): String? = runCatching { getString(key, null) }.getOrNull() diff --git a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlineFields.kt b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlineFields.kt new file mode 100644 index 0000000..403e5d7 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlineFields.kt @@ -0,0 +1,105 @@ +package dev.gf2log.app.settings + +import android.content.SharedPreferences + +/** + * One weekly cutline and every persisted spelling of it. The backup, unified-settings, and + * per-Platoon keys predate this table and must never change, and each field keeps the + * SharedPreferences value type it was first written with. + */ +internal enum class WeeklyCutlineField( + val backupKey: String, + val settingsKey: String, + val scopedKey: String, + val storedAsInt: Boolean, + val range: LongRange, +) { + DAILY_MERIT("cutline.dailyMerit", "cutline_daily_merit", "daily_merit", false, 0..Long.MAX_VALUE), + DAILY_SCORE( + "cutline.dailyGunsmokeScore", + "cutline_daily_gunsmoke_score", + "daily_score", + false, + 0..Long.MAX_VALUE, + ), + DAILY_ATTEMPTS( + "cutline.dailyGunsmokeAttempts", + "cutline_daily_gunsmoke_attempts", + "daily_attempts", + true, + 0L..3L, + ), + WEEKLY_MERIT("cutline.weeklyMerit", "cutline_weekly_merit", "weekly_merit", false, 0..Long.MAX_VALUE), + WEEKLY_SCORE( + "cutline.weeklyGunsmokeScore", + "cutline_weekly_gunsmoke_score", + "weekly_score", + false, + 0..Long.MAX_VALUE, + ), + WEEKLY_ATTEMPTS( + "cutline.weeklyGunsmokeAttempts", + "cutline_weekly_gunsmoke_attempts", + "weekly_attempts", + true, + 0L..21L, + ), + WEEKLY_LOGIN_DAYS("cutline.weeklyLoginDays", "cutline_weekly_login_days", "weekly_login", true, 0L..7L), + WEEKLY_PATROL_DAYS( + "cutline.weeklyPatrolDays", + "cutline_weekly_patrol_days", + "weekly_patrol", + true, + 0L..7L, + ), + ; + + fun valueIn(cutlines: WeeklyCutlines): Long? = when (this) { + DAILY_MERIT -> cutlines.dailyMerit + DAILY_SCORE -> cutlines.dailyGunsmokeScore + DAILY_ATTEMPTS -> cutlines.dailyGunsmokeAttempts?.toLong() + WEEKLY_MERIT -> cutlines.weeklyMerit + WEEKLY_SCORE -> cutlines.weeklyGunsmokeScore + WEEKLY_ATTEMPTS -> cutlines.weeklyGunsmokeAttempts?.toLong() + WEEKLY_LOGIN_DAYS -> cutlines.weeklyLoginDays?.toLong() + WEEKLY_PATROL_DAYS -> cutlines.weeklyPatrolDays?.toLong() + } + + companion object { + fun cutlinesOf(value: (WeeklyCutlineField) -> Long?): WeeklyCutlines = WeeklyCutlines( + dailyMerit = value(DAILY_MERIT), + dailyGunsmokeScore = value(DAILY_SCORE), + dailyGunsmokeAttempts = value(DAILY_ATTEMPTS)?.toInt(), + weeklyMerit = value(WEEKLY_MERIT), + weeklyGunsmokeScore = value(WEEKLY_SCORE), + weeklyGunsmokeAttempts = value(WEEKLY_ATTEMPTS)?.toInt(), + weeklyLoginDays = value(WEEKLY_LOGIN_DAYS)?.toInt(), + weeklyPatrolDays = value(WEEKLY_PATROL_DAYS)?.toInt(), + ) + } +} + +internal fun SharedPreferences.readWeeklyCutlines(key: (WeeklyCutlineField) -> String): WeeklyCutlines = + WeeklyCutlineField.cutlinesOf { field -> + val stored = key(field) + when { + !contains(stored) -> null + field.storedAsInt -> getInt(stored, 0).toLong() + else -> getLong(stored, 0L) + } + } + +internal fun SharedPreferences.Editor.putWeeklyCutlines( + cutlines: WeeklyCutlines, + key: (WeeklyCutlineField) -> String, +): SharedPreferences.Editor = apply { + WeeklyCutlineField.entries.forEach { field -> + val stored = key(field) + val value = field.valueIn(cutlines) + when { + value == null -> remove(stored) + field.storedAsInt -> putInt(stored, value.toInt()) + else -> putLong(stored, value) + } + } +} 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 97bd262..6905863 100644 --- a/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/WeeklyCutlinePreferences.kt @@ -49,16 +49,7 @@ class WeeklyCutlinePreferences( fun read(): WeeklyCutlines = if (isLegacy) { UserSettingsPreferences.weeklyCutlines(appContext) } else { - WeeklyCutlines( - dailyMerit = scoped.optionalLong(key(DAILY_MERIT)), - dailyGunsmokeScore = scoped.optionalLong(key(DAILY_SCORE)), - dailyGunsmokeAttempts = scoped.optionalInt(key(DAILY_ATTEMPTS)), - weeklyMerit = scoped.optionalLong(key(WEEKLY_MERIT)), - weeklyGunsmokeScore = scoped.optionalLong(key(WEEKLY_SCORE)), - weeklyGunsmokeAttempts = scoped.optionalInt(key(WEEKLY_ATTEMPTS)), - weeklyLoginDays = scoped.optionalInt(key(WEEKLY_LOGIN)), - weeklyPatrolDays = scoped.optionalInt(key(WEEKLY_PATROL)), - ) + scoped.readWeeklyCutlines(::key) } fun write(cutlines: WeeklyCutlines) { @@ -66,16 +57,9 @@ class WeeklyCutlinePreferences( UserSettingsPreferences.setWeeklyCutlines(appContext, cutlines) return } - val editor = scoped.edit() - editor.putOptionalLong(key(DAILY_MERIT), cutlines.dailyMerit) - editor.putOptionalLong(key(DAILY_SCORE), cutlines.dailyGunsmokeScore) - editor.putOptionalInt(key(DAILY_ATTEMPTS), cutlines.dailyGunsmokeAttempts) - editor.putOptionalLong(key(WEEKLY_MERIT), cutlines.weeklyMerit) - editor.putOptionalLong(key(WEEKLY_SCORE), cutlines.weeklyGunsmokeScore) - editor.putOptionalInt(key(WEEKLY_ATTEMPTS), cutlines.weeklyGunsmokeAttempts) - editor.putOptionalInt(key(WEEKLY_LOGIN), cutlines.weeklyLoginDays) - editor.putOptionalInt(key(WEEKLY_PATROL), cutlines.weeklyPatrolDays) - check(editor.commit()) { "Unable to persist weekly cutlines" } + check(scoped.edit().putWeeklyCutlines(cutlines, ::key).commit()) { + "Unable to persist weekly cutlines" + } } fun clear() { @@ -83,48 +67,17 @@ class WeeklyCutlinePreferences( write(WeeklyCutlines()) return } - val editor = scoped.edit() - listOf( - DAILY_MERIT, - DAILY_SCORE, - DAILY_ATTEMPTS, - WEEKLY_MERIT, - WEEKLY_SCORE, - WEEKLY_ATTEMPTS, - WEEKLY_LOGIN, - WEEKLY_PATROL, - ).forEach { editor.remove(key(it)) } - check(editor.commit()) { "Unable to clear weekly cutlines" } + check(scoped.edit().putWeeklyCutlines(WeeklyCutlines(), ::key).commit()) { + "Unable to clear weekly cutlines" + } } private val isLegacy: Boolean get() = storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID - private fun key(name: String) = "$storageId.$name" - - private fun android.content.SharedPreferences.optionalLong(key: String): Long? = - if (contains(key)) getLong(key, 0L) else null - - private fun android.content.SharedPreferences.optionalInt(key: String): Int? = - if (contains(key)) getInt(key, 0) else null - - private fun android.content.SharedPreferences.Editor.putOptionalLong(key: String, value: Long?) { - if (value == null) remove(key) else putLong(key, value) - } - - private fun android.content.SharedPreferences.Editor.putOptionalInt(key: String, value: Int?) { - if (value == null) remove(key) else putInt(key, value) - } + private fun key(field: WeeklyCutlineField) = "$storageId.${field.scopedKey}" private companion object { const val PROFILE_PREFERENCES = "platoon_weekly_cutlines" - const val DAILY_MERIT = "daily_merit" - const val DAILY_SCORE = "daily_score" - const val DAILY_ATTEMPTS = "daily_attempts" - const val WEEKLY_MERIT = "weekly_merit" - const val WEEKLY_SCORE = "weekly_score" - const val WEEKLY_ATTEMPTS = "weekly_attempts" - const val WEEKLY_LOGIN = "weekly_login" - const val WEEKLY_PATROL = "weekly_patrol" } } diff --git a/app/src/test/java/dev/gf2log/app/settings/WeeklyCutlineFieldTest.kt b/app/src/test/java/dev/gf2log/app/settings/WeeklyCutlineFieldTest.kt new file mode 100644 index 0000000..aced3a3 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/settings/WeeklyCutlineFieldTest.kt @@ -0,0 +1,70 @@ +package dev.gf2log.app.settings + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WeeklyCutlineFieldTest { + @Test + fun persistedKeySpellingsNeverChange() { + assertEquals( + listOf( + "cutline.dailyMerit", + "cutline.dailyGunsmokeScore", + "cutline.dailyGunsmokeAttempts", + "cutline.weeklyMerit", + "cutline.weeklyGunsmokeScore", + "cutline.weeklyGunsmokeAttempts", + "cutline.weeklyLoginDays", + "cutline.weeklyPatrolDays", + ), + WeeklyCutlineField.entries.map(WeeklyCutlineField::backupKey), + ) + assertEquals( + listOf( + "cutline_daily_merit", + "cutline_daily_gunsmoke_score", + "cutline_daily_gunsmoke_attempts", + "cutline_weekly_merit", + "cutline_weekly_gunsmoke_score", + "cutline_weekly_gunsmoke_attempts", + "cutline_weekly_login_days", + "cutline_weekly_patrol_days", + ), + WeeklyCutlineField.entries.map(WeeklyCutlineField::settingsKey), + ) + assertEquals( + listOf( + "daily_merit", + "daily_score", + "daily_attempts", + "weekly_merit", + "weekly_score", + "weekly_attempts", + "weekly_login", + "weekly_patrol", + ), + WeeklyCutlineField.entries.map(WeeklyCutlineField::scopedKey), + ) + assertEquals( + listOf(false, false, true, false, false, true, true, true), + WeeklyCutlineField.entries.map(WeeklyCutlineField::storedAsInt), + ) + } + + @Test + fun everyFieldRoundTripsThroughTheTable() { + val cutlines = WeeklyCutlines( + dailyMerit = 90, + dailyGunsmokeScore = 10_000, + dailyGunsmokeAttempts = 3, + weeklyMerit = 630, + weeklyGunsmokeScore = 70_000, + weeklyGunsmokeAttempts = 21, + weeklyLoginDays = 7, + weeklyPatrolDays = 5, + ) + + assertEquals(cutlines, WeeklyCutlineField.cutlinesOf { it.valueIn(cutlines) }) + assertEquals(WeeklyCutlines(), WeeklyCutlineField.cutlinesOf { null }) + } +} From 7d7f2a6aa34a20a6f489147806b2eb396c1dcd2f Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:30:38 +0900 Subject: [PATCH 027/125] fix: roll back partial scoped settings replacement --- .../gf2log/app/settings/AppSettingsStore.kt | 65 +++++++++++++------ .../app/settings/GameTimeZonePreferences.kt | 43 ++++++++---- .../app/settings/MemberOrderPreferences.kt | 8 +-- 3 files changed, 77 insertions(+), 39 deletions(-) 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 03a4e7a..90ec5e6 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,7 @@ package dev.gf2log.app.settings import android.content.Context +import java.time.ZoneId internal interface BackupSettingsStore { fun read(): AppBackupSettings @@ -34,30 +35,54 @@ internal class ScopedAppSettingsStore( ) } + /** + * Replaces four preference files, which SharedPreferences cannot commit atomically. Every + * value is validated before the first write, and each committed file is restored in reverse + * order if a later one fails, so a failure leaves the previous settings in place. + */ override fun replace(settings: AppBackupSettings) { - val previousGlobal = UserSettingsPreferences.read(appContext) - UserSettingsPreferences.replace( - appContext, - settings.copy( - gameServerRegion = previousGlobal.gameServerRegion, - gameTimeZoneId = previousGlobal.gameTimeZoneId, - memberOrder = previousGlobal.memberOrder, - weeklyCutlines = previousGlobal.weeklyCutlines, - ), - ) + AppBackupSettingsCodec.encode(settings) val region = GameServerRegion.fromStored(settings.gameServerRegion) - if (region == GameServerRegion.MANUAL) { - GameTimeZonePreferences.set( + val manualZone = if (region == GameServerRegion.MANUAL) ZoneId.of(settings.gameTimeZoneId) else null + val previousGlobal = UserSettingsPreferences.read(appContext) + val memberOrder = MemberOrderPreferences(appContext, storageId) + val cutlines = WeeklyCutlinePreferences(appContext, storageId) + val previousOrder = memberOrder.read() + val previousCutlines = cutlines.read() + val previousZone = GameTimeZonePreferences.scopedStateOrNull(appContext, storageId) + val rollbacks = ArrayDeque<() -> Unit>() + try { + UserSettingsPreferences.replace( appContext, - java.time.ZoneId.of(settings.gameTimeZoneId), - storageId, + settings.copy( + gameServerRegion = previousGlobal.gameServerRegion, + gameTimeZoneId = previousGlobal.gameTimeZoneId, + memberOrder = previousGlobal.memberOrder, + weeklyCutlines = previousGlobal.weeklyCutlines, + ), ) - } else { - GameTimeZonePreferences.setRegion(appContext, region, storageId) - } - check(MemberOrderPreferences(appContext, storageId).write(settings.memberOrder)) { - "Unable to restore the Platoon member order" + rollbacks.addFirst { UserSettingsPreferences.replace(appContext, previousGlobal) } + if (previousZone != null) { + rollbacks.addFirst { + GameTimeZonePreferences.restoreScopedState(appContext, storageId, previousZone) + } + } + if (manualZone != null) { + GameTimeZonePreferences.set(appContext, manualZone, storageId) + } else { + GameTimeZonePreferences.setRegion(appContext, region, storageId) + } + rollbacks.addFirst { check(memberOrder.write(previousOrder)) } + check(memberOrder.write(settings.memberOrder)) { + "Unable to restore the Platoon member order" + } + rollbacks.addFirst { cutlines.write(previousCutlines) } + cutlines.write(settings.weeklyCutlines) + } catch (error: Exception) { + rollbacks.forEach { rollback -> + runCatching(rollback).exceptionOrNull()?.let(error::addSuppressed) + } + throw error } - WeeklyCutlinePreferences(appContext, storageId).write(settings.weeklyCutlines) } } diff --git a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt index 3dc5389..30862a7 100644 --- a/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/GameTimeZonePreferences.kt @@ -34,24 +34,39 @@ internal object GameTimeZonePreferences { storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID && !scoped(context).contains(regionKey(storageId)) - fun clearRegionOverride(context: Context, storageId: String) { + /** Returns the Platoon to its profile's server region; removes both scoped keys. */ + fun clearRegionOverride(context: Context, storageId: String) = + restoreScopedState(context, storageId, ScopedZoneState(region = null, zone = null)) + + /** Profile deletion leaves no timezone keys behind; identical to [clearRegionOverride]. */ + fun clearScope(context: Context, storageId: String) = clearRegionOverride(context, storageId) + + /** The raw scoped keys, so a failed settings replacement can restore them exactly. */ + internal data class ScopedZoneState(val region: String?, val zone: String?) + + internal fun scopedState(context: Context, storageId: String): ScopedZoneState { require(storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID) - check( - scoped(context).edit() - .remove(regionKey(storageId)) - .remove(zoneKey(storageId)) - .commit(), - ) { "Unable to restore automatic server selection" } + val preferences = scoped(context) + return ScopedZoneState( + region = preferences.getString(regionKey(storageId), null), + zone = preferences.getString(zoneKey(storageId), null), + ) } - fun clearScope(context: Context, storageId: String) { + /** Null for the legacy scope, whose timezone lives in the unified settings store. */ + internal fun scopedStateOrNull(context: Context, storageId: String): ScopedZoneState? = + if (storageId == PlatoonProfileIdentity.LEGACY_STORAGE_ID) null else scopedState(context, storageId) + + internal fun restoreScopedState(context: Context, storageId: String, state: ScopedZoneState) { require(storageId != PlatoonProfileIdentity.LEGACY_STORAGE_ID) - check( - scoped(context).edit() - .remove(regionKey(storageId)) - .remove(zoneKey(storageId)) - .commit(), - ) { "Unable to clear Platoon timezone settings" } + val editor = scoped(context).edit() + if (state.region == null) { + editor.remove(regionKey(storageId)) + } else { + editor.putString(regionKey(storageId), state.region) + } + if (state.zone == null) editor.remove(zoneKey(storageId)) else editor.putString(zoneKey(storageId), state.zone) + check(editor.commit()) { "Unable to update Platoon timezone settings" } } fun setRegion( 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 4ee16ef..f30aaf1 100644 --- a/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/MemberOrderPreferences.kt @@ -32,17 +32,15 @@ class MemberOrderPreferences( if (isLegacy) { UserSettingsPreferences.clearMemberOrder(appContext) } else { - scoped.edit().remove(storageId).apply() + check(scoped.edit().remove(storageId).commit()) { "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.sortedWith( - compareBy { positions[uid(it)] ?: Int.MAX_VALUE } - .thenBy { items.indexOf(it) }, - ) + return items.sortedBy { positions[uid(it)] ?: Int.MAX_VALUE } } private val isLegacy: Boolean From 00acceec474f47f0378fdc18d22f185c8b18650d Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:32:37 +0900 Subject: [PATCH 028/125] refactor: share profile registry key and capacity handling --- .../gf2log/app/management/PlatoonProfile.kt | 143 +++++++----------- 1 file changed, 57 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt index c44b6dc..e2b6cb8 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.management import android.content.Context +import android.content.SharedPreferences import dev.gf2log.app.SupportedGamePackages import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion @@ -223,15 +224,8 @@ internal class PlatoonProfileRegistry(context: Context) { val platoonId = data.platoonId.toLong() val existing = readAllLocked().firstOrNull { it.hasIdentity(client, region, platoonId) } val storageId = existing?.storageId ?: allocateStorageIdLocked(client, region, platoonId) - require(readLocked(storageId) != null || readAllLocked().size < MAX_PROFILES) { - "Too many Platoon profiles are already registered" - } - val normalizedName = data.platoonName - .replace(Regex("\\s+"), " ") - .filterNot(Char::isISOControl) - .trim() - .take(PlatoonProfile.MAX_NAME_LENGTH) - require(normalizedName.isNotBlank()) { "Platoon name is empty after normalization" } + requireCapacityLocked(storageId) + val normalizedName = normalizeName(data.platoonName) val bannerIds = normalizedBannerIds( data = data, fallbackFrameId = existing?.bannerFrameId ?: 0L, @@ -270,11 +264,10 @@ internal class PlatoonProfileRegistry(context: Context) { require(readAllLocked().none { it.hasIdentity(client, region, platoonId) }) { "That client/server Platoon profile already exists" } - require(readAllLocked().size < MAX_PROFILES) { - "Too many Platoon profiles are already registered" - } + val storageId = allocateStorageIdLocked(client, region, platoonId) + requireCapacityLocked(storageId) val profile = PlatoonProfile( - storageId = allocateStorageIdLocked(client, region, platoonId), + storageId = storageId, client = client, serverRegion = region, platoonId = platoonId, @@ -343,9 +336,7 @@ internal class PlatoonProfileRegistry(context: Context) { fun upsertRestored(profile: PlatoonProfile): PlatoonProfile = synchronized(lock) { require(!profile.legacy) { "Legacy unscoped Platoon data cannot be restored" } requireCompatibleRestoreTargetLocked(profile) - require(readLocked(profile.storageId) != null || readAllLocked().size < MAX_PROFILES) { - "Too many Platoon profiles are already registered" - } + requireCapacityLocked(profile.storageId) writeLocked(profile, setActive = false) profile } @@ -354,9 +345,7 @@ internal class PlatoonProfileRegistry(context: Context) { fun requireRestoreCapacity(profile: PlatoonProfile) = synchronized(lock) { require(!profile.legacy) { "Legacy unscoped Platoon data cannot be restored" } requireCompatibleRestoreTargetLocked(profile) - require(readLocked(profile.storageId) != null || readAllLocked().size < MAX_PROFILES) { - "Too many Platoon profiles are already registered" - } + requireCapacityLocked(profile.storageId) } /** Removes registry metadata introduced by a failed restore; scoped data is left untouched. */ @@ -366,19 +355,9 @@ internal class PlatoonProfileRegistry(context: Context) { if (preferences.getString(KEY_ACTIVE, null) == storageId) return@synchronized false val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() if (!ids.remove(storageId)) return@synchronized false - val prefix = "$KEY_PROFILE.$storageId." preferences.edit() .putStringSet(KEY_IDS, ids) - .remove(prefix + CLIENT) - .remove(prefix + REGION) - .remove(prefix + PLATOON_ID) - .remove(prefix + NAME) - .remove(prefix + BANNER_FRAME_ID) - .remove(prefix + BANNER_MARK_ID) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .remove(prefix + LAST_SEEN) - .remove(prefix + LEGACY) + .removeProfileKeys(storageId) .commit() } @@ -395,32 +374,13 @@ internal class PlatoonProfileRegistry(context: Context) { PlatoonProfileIdentity.isValidStorageId(previousActiveStorageId), ) val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() - val prefix = "$KEY_PROFILE.$targetStorageId." val editor = preferences.edit() if (previousProfile == null) { ids.remove(targetStorageId) - editor.remove(prefix + CLIENT) - .remove(prefix + REGION) - .remove(prefix + PLATOON_ID) - .remove(prefix + NAME) - .remove(prefix + BANNER_FRAME_ID) - .remove(prefix + BANNER_MARK_ID) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .remove(prefix + LAST_SEEN) - .remove(prefix + LEGACY) + editor.removeProfileKeys(targetStorageId) } else { ids += targetStorageId - editor.putString(prefix + CLIENT, previousProfile.client.name) - .putString(prefix + REGION, previousProfile.serverRegion.storedValue) - .putLong(prefix + PLATOON_ID, previousProfile.platoonId) - .putString(prefix + NAME, previousProfile.platoonName) - .putLong(prefix + BANNER_FRAME_ID, previousProfile.bannerFrameId) - .putLong(prefix + BANNER_MARK_ID, previousProfile.bannerMarkId) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .putLong(prefix + LAST_SEEN, previousProfile.lastSeenAt.toEpochMilli()) - .putBoolean(prefix + LEGACY, previousProfile.legacy) + editor.putProfile(previousProfile) } editor.putStringSet(KEY_IDS, ids) if (previousActiveStorageId != null && previousActiveStorageId in ids) { @@ -439,19 +399,9 @@ internal class PlatoonProfileRegistry(context: Context) { val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() if (!ids.remove(storageId)) return@synchronized false val remaining = ids.mapNotNull(::readLocked) - val prefix = "$KEY_PROFILE.$storageId." val editor = preferences.edit() .putStringSet(KEY_IDS, ids) - .remove(prefix + CLIENT) - .remove(prefix + REGION) - .remove(prefix + PLATOON_ID) - .remove(prefix + NAME) - .remove(prefix + BANNER_FRAME_ID) - .remove(prefix + BANNER_MARK_ID) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .remove(prefix + LAST_SEEN) - .remove(prefix + LEGACY) + .removeProfileKeys(storageId) if (preferences.getString(KEY_ACTIVE, null) == storageId) { val fallback = remaining.maxByOrNull(PlatoonProfile::lastSeenAt) if (fallback == null) { @@ -474,19 +424,9 @@ internal class PlatoonProfileRegistry(context: Context) { if (!ids.remove(PlatoonProfileIdentity.LEGACY_STORAGE_ID) && preferences.getString(KEY_ACTIVE, null) != PlatoonProfileIdentity.LEGACY_STORAGE_ID ) return - val prefix = "$KEY_PROFILE.${PlatoonProfileIdentity.LEGACY_STORAGE_ID}." val editor = preferences.edit() .putStringSet(KEY_IDS, ids) - .remove(prefix + CLIENT) - .remove(prefix + REGION) - .remove(prefix + PLATOON_ID) - .remove(prefix + NAME) - .remove(prefix + BANNER_FRAME_ID) - .remove(prefix + BANNER_MARK_ID) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .remove(prefix + LAST_SEEN) - .remove(prefix + LEGACY) + .removeProfileKeys(PlatoonProfileIdentity.LEGACY_STORAGE_ID) if (preferences.getString(KEY_ACTIVE, null) == PlatoonProfileIdentity.LEGACY_STORAGE_ID) { editor.remove(KEY_ACTIVE) } @@ -495,7 +435,7 @@ internal class PlatoonProfileRegistry(context: Context) { private fun readLocked(storageId: String): PlatoonProfile? = runCatching { if (!PlatoonProfileIdentity.isValidStorageId(storageId)) return@runCatching null - val prefix = "$KEY_PROFILE.$storageId." + val prefix = profilePrefix(storageId) PlatoonProfile( storageId = storageId, client = PlatoonClient.valueOf(requireNotNull(preferences.getString(prefix + CLIENT, null))), @@ -512,23 +452,41 @@ internal class PlatoonProfileRegistry(context: Context) { private fun writeLocked(profile: PlatoonProfile, setActive: Boolean) { val ids = preferences.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() ids += profile.storageId - val prefix = "$KEY_PROFILE.${profile.storageId}." val editor = preferences.edit() .putStringSet(KEY_IDS, ids) - .putString(prefix + CLIENT, profile.client.name) - .putString(prefix + REGION, profile.serverRegion.storedValue) - .putLong(prefix + PLATOON_ID, profile.platoonId) - .putString(prefix + NAME, profile.platoonName) - .putLong(prefix + BANNER_FRAME_ID, profile.bannerFrameId) - .putLong(prefix + BANNER_MARK_ID, profile.bannerMarkId) - .remove(prefix + EMBLEM_PRIMARY) - .remove(prefix + EMBLEM_SECONDARY) - .putLong(prefix + LAST_SEEN, profile.lastSeenAt.toEpochMilli()) - .putBoolean(prefix + LEGACY, profile.legacy) + .putProfile(profile) if (setActive) editor.putString(KEY_ACTIVE, profile.storageId) check(editor.commit()) { "Unable to persist the Platoon profile registry" } } + private fun SharedPreferences.Editor.putProfile(profile: PlatoonProfile) = apply { + val prefix = profilePrefix(profile.storageId) + putString(prefix + CLIENT, profile.client.name) + putString(prefix + REGION, profile.serverRegion.storedValue) + putLong(prefix + PLATOON_ID, profile.platoonId) + putString(prefix + NAME, profile.platoonName) + putLong(prefix + BANNER_FRAME_ID, profile.bannerFrameId) + putLong(prefix + BANNER_MARK_ID, profile.bannerMarkId) + remove(prefix + EMBLEM_PRIMARY) + remove(prefix + EMBLEM_SECONDARY) + putLong(prefix + LAST_SEEN, profile.lastSeenAt.toEpochMilli()) + putBoolean(prefix + LEGACY, profile.legacy) + } + + private fun SharedPreferences.Editor.removeProfileKeys(storageId: String) = apply { + val prefix = profilePrefix(storageId) + PROFILE_KEYS.forEach { key -> remove(prefix + key) } + } + + private fun profilePrefix(storageId: String) = "$KEY_PROFILE.$storageId." + + /** A new scope must fit under [MAX_PROFILES]; an existing scope is always replaceable. */ + private fun requireCapacityLocked(storageId: String) { + require(readLocked(storageId) != null || readAllLocked().size < MAX_PROFILES) { + "Too many Platoon profiles are already registered" + } + } + private fun requireCompatibleRestoreTargetLocked(profile: PlatoonProfile) { require(!profile.legacy) { "Legacy unscoped Platoon data cannot be restored" } val profiles = readAllLocked() @@ -567,7 +525,7 @@ internal class PlatoonProfileRegistry(context: Context) { } private fun normalizeName(value: String): String = value - .replace(Regex("\\s+"), " ") + .replace(WHITESPACE, " ") .filterNot(Char::isISOControl) .trim() .take(PlatoonProfile.MAX_NAME_LENGTH) @@ -609,6 +567,19 @@ internal class PlatoonProfileRegistry(context: Context) { private const val EMBLEM_SECONDARY = "emblem_secondary" private const val LAST_SEEN = "last_seen" private const val LEGACY = "legacy" + private val PROFILE_KEYS = listOf( + CLIENT, + REGION, + PLATOON_ID, + NAME, + BANNER_FRAME_ID, + BANNER_MARK_ID, + EMBLEM_PRIMARY, + EMBLEM_SECONDARY, + LAST_SEEN, + LEGACY, + ) + private val WHITESPACE = Regex("\\s+") private val lock = Any() } } From 40d0ce43439fe3671f8bcd36c7d46e12054161cf Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:32:42 +0900 Subject: [PATCH 029/125] refactor: share hex and SHA-256 helpers --- .../gf2log/app/management/BackupArchive.kt | 30 +++--------------- .../java/dev/gf2log/app/management/Digests.kt | 31 +++++++++++++++++++ .../app/management/PlatoonBackupManager.kt | 16 ++-------- .../app/management/PlatoonCsvImportStore.kt | 5 +-- .../gf2log/app/management/PlatoonProfile.kt | 10 ++---- .../management/WeeklyReportHistoryCodec.kt | 5 +-- 6 files changed, 42 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/management/Digests.kt diff --git a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt index 120567c..1d19498 100644 --- a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt +++ b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt @@ -9,7 +9,6 @@ import java.io.File import java.io.FilterOutputStream import java.io.InputStream import java.io.OutputStream -import java.security.MessageDigest import java.util.Properties import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @@ -38,7 +37,7 @@ internal object BackupArchive { zip.putNextEntry(ZipEntry(MANIFEST_ENTRY)) Properties().apply { setProperty(KEY_FORMAT_VERSION, formatVersion.toString()) - setProperty(KEY_DATABASE_SHA256, database.sha256()) + setProperty(KEY_DATABASE_SHA256, database.sha256Hex()) if (profile != null) { setProperty(KEY_APPLICATION_ID, APPLICATION_ID) setProperty( @@ -46,11 +45,11 @@ internal object BackupArchive { if (settings == null) PLATOON_SCOPE else COMPLETE_SCOPE, ) putProfile(profile) - if (settings != null) setProperty(KEY_SETTINGS_SHA256, settings.sha256()) + if (settings != null) setProperty(KEY_SETTINGS_SHA256, settings.sha256().toHex()) } else if (settings != null) { setProperty(KEY_APPLICATION_ID, APPLICATION_ID) setProperty(KEY_BACKUP_SCOPE, COMPLETE_SCOPE) - setProperty(KEY_SETTINGS_SHA256, settings.sha256()) + setProperty(KEY_SETTINGS_SHA256, settings.sha256().toHex()) } }.store(zip, "mobileGF2logger backup") zip.closeEntry() @@ -143,7 +142,7 @@ internal object BackupArchive { } require( metadata.getProperty(KEY_DATABASE_SHA256) - .equals(stagedDatabase.sha256(), ignoreCase = true), + .equals(stagedDatabase.sha256Hex(), ignoreCase = true), ) { "Backup database checksum does not match" } val profile = if ( @@ -176,7 +175,7 @@ internal object BackupArchive { val settings = requireNotNull(settingsBytes) { "Backup settings are missing" } require( metadata.getProperty(KEY_SETTINGS_SHA256) - .equals(settings.sha256(), ignoreCase = true), + .equals(settings.sha256().toHex(), ignoreCase = true), ) { "Backup settings checksum does not match" } } else { require(settingsBytes == null) { "Legacy backup contains unexpected settings" } @@ -285,25 +284,6 @@ internal object BackupArchive { } } - private fun File.sha256(): String { - val digest = MessageDigest.getInstance("SHA-256") - inputStream().use { input -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val count = input.read(buffer) - if (count < 0) break - digest.update(buffer, 0, count) - } - } - return digest.digest().hex() - } - - private fun ByteArray.sha256(): String = MessageDigest.getInstance("SHA-256") - .digest(this) - .hex() - - private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } - /** Lets ZIP cleanup release its deflater without closing the caller-owned destination. */ private class NonClosingOutputStream(output: OutputStream) : FilterOutputStream(output) { override fun close() = flush() diff --git a/app/src/main/java/dev/gf2log/app/management/Digests.kt b/app/src/main/java/dev/gf2log/app/management/Digests.kt new file mode 100644 index 0000000..724033f --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/Digests.kt @@ -0,0 +1,31 @@ +package dev.gf2log.app.management + +import java.io.File +import java.security.MessageDigest + +private val HEX_DIGITS = "0123456789abcdef".toCharArray() + +/** Lowercase hexadecimal, two characters per byte. */ +internal fun ByteArray.toHex(): String = buildString(size * 2) { + this@toHex.forEach { byte -> + val value = byte.toInt() and 0xff + append(HEX_DIGITS[value ushr 4]) + append(HEX_DIGITS[value and 0x0f]) + } +} + +internal fun ByteArray.sha256(): ByteArray = MessageDigest.getInstance("SHA-256").digest(this) + +/** Streams the file through SHA-256 and returns the lowercase hexadecimal digest. */ +internal fun File.sha256Hex(): String { + val digest = MessageDigest.getInstance("SHA-256") + inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().toHex() +} 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 f0bb95e..3d05601 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -14,7 +14,6 @@ import java.io.EOFException import java.io.File import java.io.InputStream import java.io.OutputStream -import java.security.MessageDigest import java.util.zip.ZipException class PlatoonBackupManager internal constructor( @@ -62,20 +61,9 @@ class PlatoonBackupManager internal constructor( /** Returns a stable digest after closing SQLite so WAL state is checkpointed. */ internal fun currentDatabaseSha256(): String = PlatoonRepository.withExclusiveDatabase(storageScope) { - ensureDatabaseExists() - val digest = MessageDigest.getInstance("SHA-256") - databaseFile.inputStream().use { input -> - val buffer = ByteArray(8 * 1024) - while (true) { - val count = input.read(buffer) - if (count < 0) break - digest.update(buffer, 0, count) - } - } - digest.digest().joinToString("") { value -> - String.format("%02x", value.toInt() and 0xff) + ensureDatabaseExists() + databaseFile.sha256Hex() } - } fun exportFull(output: OutputStream) { PlatoonRepository(appContext, storageScope).reconcileRetainedCsvFiles() 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 ff19aed..b8fd25e 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt @@ -11,7 +11,6 @@ import java.nio.charset.StandardCharsets import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption -import java.security.MessageDigest import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter @@ -36,9 +35,7 @@ class PlatoonCsvImportStore(private val directory: File) { val parsed = requireNotNull(GuildMembersCsv.parse(text)) { "Invalid Platoon CSV" } require(parsed.members.isNotEmpty()) { "Platoon CSV contains no roster members" } val capturedAt = Instant.parse(parsed.logTime) - val hash = MessageDigest.getInstance("SHA-256").digest(bytes) - .take(HASH_BYTES) - .joinToString("") { "%02x".format(it) } + val hash = bytes.sha256().copyOf(HASH_BYTES).toHex() return PreparedImport( encoded = bytes, snapshot = parsed, diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt index e2b6cb8..ecb9fb2 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfile.kt @@ -7,7 +7,6 @@ import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.protocol.model.PlatoonProfileData import java.io.File -import java.security.MessageDigest import java.security.SecureRandom import java.time.Instant @@ -84,17 +83,12 @@ internal object PlatoonProfileIdentity { require(platoonId > 0L) val material = "${client.packageName}\u0000${region.storedValue}\u0000$platoonId" .toByteArray(Charsets.UTF_8) - return MessageDigest.getInstance("SHA-256") - .digest(material) - .take(16) - .joinToString("") { value -> "%02x".format(value) } + return material.sha256().copyOf(16).toHex() } fun isValidStorageId(value: String): Boolean = STORAGE_ID.matches(value) - fun randomStorageId(): String = ByteArray(16) - .also(SecureRandom()::nextBytes) - .joinToString("") { value -> "%02x".format(value) } + fun randomStorageId(): String = ByteArray(16).also(SecureRandom()::nextBytes).toHex() } /** Resolves every database and retained-evidence path from one validated profile ID. */ diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt index 7327098..6f628ae 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt @@ -5,7 +5,6 @@ import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream import java.nio.charset.StandardCharsets -import java.security.MessageDigest import java.time.Instant import java.time.LocalDate import java.util.zip.GZIPInputStream @@ -54,9 +53,7 @@ internal object WeeklyReportHistoryCodec { compressed.toByteArray() } require(payload.size <= MAX_PAYLOAD_BYTES) { "Weekly history payload is too large" } - val fingerprint = MessageDigest.getInstance("SHA-256") - .digest(raw) - .joinToString("") { "%02x".format(it) } + val fingerprint = raw.sha256().toHex() return Encoded(payload, fingerprint) } From beda296d8415bc578dd8eefe32bdb305fbc18a7e Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:32:56 +0900 Subject: [PATCH 030/125] chore: remove unused resources and legacy status accessors Drop 96 unused strings, colors, and icons, the deprecated string-based CaptureStatus readers that held an application Context in a static field, and an always-true SDK check. --- .../dev/gf2log/app/capture/CaptureStatus.kt | 24 ------ .../gf2log/app/capture/CaptureVpnService.kt | 1 - app/src/main/java/dev/gf2log/app/ui/Views.kt | 4 +- app/src/main/res/drawable/ic_notes.xml | 9 -- app/src/main/res/drawable/ic_palette.xml | 9 -- app/src/main/res/drawable/ic_person.xml | 9 -- app/src/main/res/drawable/ic_person_add.xml | 9 -- app/src/main/res/drawable/ic_table.xml | 9 -- app/src/main/res/drawable/ic_translate.xml | 9 -- app/src/main/res/drawable/ic_tune.xml | 9 -- app/src/main/res/values-ko/strings.xml | 85 ------------------- app/src/main/res/values-night/colors.xml | 4 - app/src/main/res/values/colors.xml | 4 - app/src/main/res/values/strings.xml | 85 ------------------- .../gf2log/app/capture/CaptureStatusTest.kt | 10 --- 15 files changed, 1 insertion(+), 279 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_notes.xml delete mode 100644 app/src/main/res/drawable/ic_palette.xml delete mode 100644 app/src/main/res/drawable/ic_person.xml delete mode 100644 app/src/main/res/drawable/ic_person_add.xml delete mode 100644 app/src/main/res/drawable/ic_table.xml delete mode 100644 app/src/main/res/drawable/ic_translate.xml delete mode 100644 app/src/main/res/drawable/ic_tune.xml 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 772f1b9..2c6c24b 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt @@ -94,8 +94,6 @@ object CaptureStatus { private val listeners = CopyOnWriteArraySet<(CaptureSnapshot) -> Unit>() private val dispatchPending = AtomicBoolean(false) - @Volatile - private var legacyContext: Context? = null fun snapshot(): CaptureSnapshot = state.get() @@ -151,26 +149,6 @@ object CaptureStatus { current.copy(guided = guided.copy(capturedPayloadTypes = guided.capturedPayloadTypes + payloadType)) } - /** Lets the deprecated [read] localize notices until the UI moves to [snapshot]. */ - internal fun attachLegacyContext(context: Context) { - legacyContext = context.applicationContext - } - - @Deprecated("Use snapshot() and CaptureNoticeEvent.format()") - fun read(): String { - val current = snapshot() - val notice = current.notice - ?: return if (current.phase == CapturePhase.STARTING) LEGACY_PREPARING else LEGACY_STOPPED - return when (notice.notice) { - CaptureNotice.PREPARING -> LEGACY_PREPARING - CaptureNotice.CAPTURING -> "Capturing only " + notice.args.joinToString() - else -> legacyContext?.let(notice::format) ?: notice.notice.name - } - } - - @Deprecated("Use snapshot().guided") - fun readGuidedProgress(): GuidedProgress? = snapshot().guided - private inline fun update(transform: (CaptureSnapshot) -> CaptureSnapshot) { while (true) { val previous = state.get() @@ -193,8 +171,6 @@ object CaptureStatus { private fun event(notice: CaptureNotice, args: Array = emptyArray()) = CaptureNoticeEvent(notice, args.toList(), System.currentTimeMillis()) - private const val LEGACY_STOPPED = "Capture is stopped" - private const val LEGACY_PREPARING = "Preparing capture" data class GuidedProgress( val capturedPayloadTypes: Set = emptySet(), 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 274527d..d83f776 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -46,7 +46,6 @@ class CaptureVpnService : VpnService() { override fun onCreate() { super.onCreate() - CaptureStatus.attachLegacyContext(this) profileRegistry = PlatoonProfileRegistry(this) diagnosticsStore = CaptureDiagnosticsStore(this) notifications = CaptureNotifications(this).also { it.createChannel() } 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 74c0dc0..e05c6e0 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Views.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -152,9 +152,7 @@ internal fun Context.iconButton( scaleType = ImageView.ScaleType.CENTER minimumWidth = dp(48) minimumHeight = dp(48) - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - tooltipText = description - } + tooltipText = description setOnClickListener(onClick) } diff --git a/app/src/main/res/drawable/ic_notes.xml b/app/src/main/res/drawable/ic_notes.xml deleted file mode 100644 index d36a00f..0000000 --- a/app/src/main/res/drawable/ic_notes.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_palette.xml b/app/src/main/res/drawable/ic_palette.xml deleted file mode 100644 index add9393..0000000 --- a/app/src/main/res/drawable/ic_palette.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml deleted file mode 100644 index b1fd870..0000000 --- a/app/src/main/res/drawable/ic_person.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_person_add.xml b/app/src/main/res/drawable/ic_person_add.xml deleted file mode 100644 index b17aea5..0000000 --- a/app/src/main/res/drawable/ic_person_add.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_table.xml b/app/src/main/res/drawable/ic_table.xml deleted file mode 100644 index 63848dd..0000000 --- a/app/src/main/res/drawable/ic_table.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_translate.xml b/app/src/main/res/drawable/ic_translate.xml deleted file mode 100644 index aba5c34..0000000 --- a/app/src/main/res/drawable/ic_translate.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_tune.xml b/app/src/main/res/drawable/ic_tune.xml deleted file mode 100644 index 7ce6ae6..0000000 --- a/app/src/main/res/drawable/ic_tune.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 5fc905e..dc84327 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,43 +1,27 @@ GF2logger - 캡처 워크스페이스 데이터 도구 캡처 상태 준비됨 캡처 중 - 캡처가 중지되었습니다 - 지원 클라이언트 ID - 패키지 ID - 패키지 ID - com.haoplay.game.and.exilium\nHaoPlay 클라이언트\n\ncom.Sunborn.SnqxExilium.Glo\nDarkwinter 클라이언트 홈 서클 주간 - 전체 보기 - 설치된 HaoPlay 및 Darkwinter 클라이언트의 프로토콜을 기기에서 캡처합니다. 원본 트래픽은 저장하지 않습니다. 계속 캡처 한 번만 캡처 - %1$s 서클 식별 정보\n%2$s 서클 명단\n%3$s 활동 근거\n%4$s 가입/탈퇴 변경 중지 설정 열기 서클 관리 백업 내보내기 - 서클 관리 백업 가져오기 - 백업에 포함된 서클의 관리 데이터를 대체합니다. 다른 서클의 데이터는 변경하지 않습니다. 캡처를 중지한 상태로 유지하세요. 백업을 내보내거나 가져오기 전에 캡처를 중지하세요. 서클 관리 백업을 내보냈습니다 - 서클 관리 백업을 복원했습니다 - 서클 관리 백업을 내보내거나 복원할 수 없습니다 - 올바른 서클 관리 백업 파일이 아닙니다. 언어 English - 한국어 한국어 화면 테마 시스템 설정 라이트 다크 - 기기 설정을 따르거나 GF2logger를 라이트 또는 다크 모드로 고정합니다. 건너뛰기 이전 다음 @@ -73,10 +57,8 @@ 패킷에 담겨 있는 여러 정보를 통해 멤버 및 주간 테이블을 완성해 나갈 수 있습니다. 백업 백업 복원 - 전체 백업에 포함된 서클의 모든 관리 데이터와 앱 설정을 복원합니다. 다른 서클은 변경하지 않습니다. 전체 정보 백업하기 앱 설정과 선택한 서클의 가입 이력 및 주간표를 포함한 모든 관리 데이터를 저장합니다. - 앱 설정과 백업에 포함된 서클 데이터를 대체한 뒤 해당 서클을 선택합니다. 다른 서클은 변경하지 않습니다. 복원하는 동안 캡처를 중지하세요. 올바른 전체 백업 파일이 아닙니다. 전체 정보 백업을 저장했습니다 전체 정보 백업을 복원했습니다 @@ -87,18 +69,15 @@ 주간 과업표 최근 스냅샷 비교 선택 멤버 내보내기 - %1$s 선택 먼저 멤버를 하나 이상 선택하세요 선택 멤버를 내보냈습니다 이름 또는 UID 검색 - 가입 + 탈퇴 가입 이름 이번 주 공적 누적 공적 최근 접속 아직 서클 멤버 스냅샷을 캡처하지 않았습니다. - 가입 %1$d명 · 탈퇴 %2$d명\n최근 스냅샷: %3$s (%4$s) 일치하는 멤버가 없습니다 이번 주 공적 누적 공적 @@ -107,15 +86,11 @@ %1$s → %2$s 가입 탈퇴 - 멤버 변화 흙먼지 점수 없음 멤버 상세 가입 멤버 탈퇴 멤버 - #%1$d · %2$s - 멤버 이름 - 비고 닉네임 개인 메모 저장 @@ -129,22 +104,15 @@ 멤버를 삭제했습니다 멤버를 삭제할 수 없습니다 탈퇴한 멤버 수동 추가 - 가입: - 탈퇴: 메모 - 미설정 날짜 선택 시간 (선택) - 먼저 날짜를 선택하세요 날짜 지우기 시간 지우기 UID 추가 - 지우기 중복되지 않는 양의 UID와 닉네임을 입력하고 가입·탈퇴 날짜를 선택한 뒤 탈퇴를 가입 이후로 설정하세요. - 가입 이력 %1$d\n가입: %2$s\n탈퇴: %3$s 가입 이력 %1$d - %1$s → %2$s 가입 이력 삭제 가입/탈퇴 이력을 삭제합니다. 한 번 삭제하면 되돌릴 수 없습니다. 계속하시겠습니까? 가입 이력을 삭제했습니다 @@ -152,26 +120,17 @@ 유일한 가입 이력은 삭제할 수 없습니다. 이 기록을 없애려면 멤버 삭제를 이용해 주세요. 알 수 없음 가입·탈퇴 시각 편집 - 가입 (yyyy-MM-dd HH:mm), 모르면 비워 두기 - 탈퇴 (yyyy-MM-dd HH:mm), 가입/모르면 비워 두기 가입 날짜를 선택하고 탈퇴를 가입 이후로 설정하세요. 시간은 선택 사항입니다 흙먼지 주간 일반 주간 - %1$s – %2$s · 05:00 초기화 (%3$s) 이전 주 - 이번 주 다음 주 주간 과업표를 불러오는 중… 주간 과업표를 불러올 수 없습니다. 다시 시도 서로 다른 게임 일자에 서클 멤버 화면을 캡처하거나 명단 CSV를 가져오면 이 주의 표가 만들어집니다. 멤버 - 순위 합계 - 공적 %1$d\n출석 %2$s · 과업 %3$s - 공적 %1$d\n%2$d점 · %3$s회\n출석 %4$s · 과업 %5$s - 공적 %1$d - 공적 %1$d\n%2$d점 · %3$s회 비고 가입/탈퇴 가입 @@ -181,28 +140,18 @@ 비고 추가 이번 주에는 최대 128개의 비고가 이미 저장되어 있습니다. 비고 삭제 - 이 비고를 삭제할까요? 삭제 비고를 삭제했습니다 주간 CSV 내보내기 주간 CSV를 내보냈습니다 전체 주간 과업표 내보내기 내보낼 주간 과업표가 없습니다. - 가입 - 재가입 - 탈퇴 설정 앞으로 기록할 디코딩 페이로드를 선택합니다. 기존 기록에는 영향을 주지 않습니다. - 패킷과 보고서의 표기 언어에도 함께 적용됩니다. - 기기 설정을 따르거나 라이트·다크 모드로 고정합니다. - 게임 시간대 - 하루 초기화 시간 - 게임 서버 지역을 선택하면 서버의 05:00 초기화 시각을 이 기기의 시간대로 환산합니다. 서버 지역 자동 선택 선택되지 않음 — 첫 캡처 전에 선택하세요 자동 · %1$s - 서클 프로필이 감지된 뒤 자동 선택을 사용할 수 있습니다. 시간대 수동 설정 Darkwinter 글로벌 Darkwinter 중국 @@ -221,11 +170,8 @@ 서버 선택을 취소하면 수집한 패킷이 저장되지 않을 수 있습니다. 확인되지 않은 서클의 패킷을 버리시겠습니까? 서클 관리 감지된 서클의 서버를 수정하거나 격리된 데이터를 완전히 삭제합니다. - %1$s · %2$s · ID %3$d 선택됨 - 클라이언트/서버 수정 %1$s 서버 수정 - 확인된 클라이언트: %1$s (수정 불가)\n%2$s 서클의 서버를 선택하세요. 서클을 수정하거나 삭제하기 전에 캡처를 중지하세요. 서클 서버를 수정할 수 없습니다. 데이터는 이동되지 않았습니다. 서클 삭제 @@ -236,13 +182,10 @@ 서클 삭제를 완료할 수 없습니다. 안전하게 다시 정리합니다. %1$s · 이 기기의 다음 초기화 %2$s (%3$s) 수동 · %1$s (기기: %2$s) - 게임: %1$s · 기기: %2$s 게임 시간대를 변경하고 주간 표 히스토리를 다시 구성했습니다 시간대 변경과 주간 표 히스토리 재구성을 완료하지 못했습니다 사용자가 직접 선택한 백업 파일로만 전체 앱 데이터를 이동합니다. 뒤로 - %1$s (payloadType = %2$d) - 필수 항목으로 항상 활성화됩니다. %1$s %1$s, 페이로드 유형 %2$s 알 수 없음 서클 멤버 @@ -268,28 +211,17 @@ 보유 무기 파츠의 레벨, 세트 플래그와 잠금 상태입니다. 보유 공용 스킬 아이템입니다. id는 플레이어 UID가 아니라 창고 항목 ID입니다. 저장한 편성과 장착물 정보입니다. - 최근 파싱한 패킷 (최대 %1$d개) - 선택 기록 삭제 - 선택 기록 저장 - 보관 패킷 (최대 %1$d개) - 선택 보관 기록 삭제 최신 명단 CSV 내보내기 명단 CSV 가져오기 - CSV 가져오기 완료: 보관 %1$d, 현재 %2$d, 과거 %3$d, 건너뜀 %4$d 선택한 서클 CSV 파일을 가져오지 못했습니다 아직 캡처한 패킷이 없습니다 아직 보관한 패킷이 없습니다 - %1$s 선택 - 원본 CSV 보기 파싱 패킷 복사 GF2logger 파싱 패킷 파싱 패킷을 복사했습니다 캡처 준비 중 - %1$s 내보냄 서클 CSV를 내보낼 수 없습니다 캡처한 서클 CSV가 없습니다 - 먼저 기록을 하나 이상 선택하세요 - 먼저 보관 기록을 하나 이상 선택하세요 선택 기록을 저장할 수 없습니다 선택한 기록은 이미 보관되어 있습니다 보관 한도에 도달했습니다 (%1$d) @@ -311,7 +243,6 @@ 주간 과업 참여 일수 최소값 설정 저장 - 0 이상의 올바른 기준값을 입력하세요. 멤버 순서 주간 표는 이 순서로 멤버를 표시합니다. 손잡이를 끌어 조정하면 자동으로 저장됩니다. 정렬 기준 @@ -343,10 +274,6 @@ 확인 취소 주간 과업표 수정사항을 저장했습니다. - 0 이상의 숫자를 입력하세요. 흙먼지 참여횟수는 0에서 3 사이여야 합니다. - ≥ 표시는 아직 끝나지 않은 날짜에서 캡처 시점까지 확인된 최솟값입니다.\n? 표시는 현재 캡처만으로 해당 날짜를 확정할 수 없음을 뜻합니다.\n완료된 날짜에 가능한 0/50/90점 배치가 여러 개라면 임의의 추정값을 표시하지 않고 알 수 없음으로 유지합니다.\n건너뛴 기간이 지나치게 길 경우 이전 내역이 전부 ?로 표시될 수 있습니다. - ≥ 표시는 확인된 최솟값이며, ? 표시는 현재 캡처만으로 해당 날짜를 안전하게 나눌 수 없음을 뜻합니다.\n누락된 흙먼지 날짜의 공적, 점수, 참여횟수, 출석, 일일 과업은 안전하게 나눌 수 없으므로 카운터로 보강된 합계를 확인하고 아는 사실만 직접 수정하세요.\n건너뛴 기간이 지나치게 길 경우 이전 내역이 전부 ?로 표시될 수 있습니다. - %1$s 확인 기록 %1$d개를 삭제했습니다 @@ -359,18 +286,13 @@ 보관 기록 %1$d개를 삭제했습니다 - 근거 상태 근거 상태 읽는 법 - ✓ 확정 · ≥ 확인된 최솟값 · ? 알 수 없음 — 자세히 보기 마감 경계, 직접 이벤트, 수동 수정 또는 가능한 모든 배치의 일치로 정확한 값이 확정되었습니다. 현재 캡처로 최소 이 값은 확인되지만, 누락된 근거에 따라 최종 값은 더 높을 수 있습니다. 현재 캡처만으로 안전하게 확정할 수 없어 GF2logger가 임의로 추측하지 않습니다. 완료된 날짜에는 유효한 일일 공적 배치만 사용합니다. 가능한 0/50/90 조합이 여러 개면 알 수 없음으로 유지합니다. 누락된 흙먼지 날짜를 안전하게 나눌 수 없어도 카운터로 확인된 주간 합계는 계속 활용할 수 있습니다. 캡처 공백이 길면 이전 항목이 알 수 없음으로 남을 수 있습니다. 각 셀을 누르면 사용된 근거를 확인할 수 있습니다. - 관측 일수 %1$d/%2$d · 확정 %3$d · 최솟값 %4$d · 알 수 없음 %5$d\n직접 로그인 %6$d일 · 직접 과업 %7$d일 · 마감 경계 %8$d개 - %1$s · %2$s · %3$s - 판정: %1$s\n\n근거:\n%2$s 확정 확인된 최솟값이며 최종 값은 더 높을 수 있음 현재 캡처만으로 알 수 없음 @@ -390,8 +312,6 @@ 긴 공백 때문에 일일 배치를 하나로 확정할 수 없습니다. 가능한 배치가 둘 이상이므로 애플리케이션은 임의로 추측하지 않습니다. CSV 가져오기 검토 - 적용 대상: %1$s (ID %2$d)\n%3$s · %4$s\n\n21917만으로는 적용 대상을 확인할 수 없습니다. 선택한 서클이 맞는지 확인하세요. - 검증된 파일: %1$d\n이미 존재함: %2$d\n과거 캡처: %3$d\n포함된 멤버: %4$d\n새 UID: %5$d\n이름 차이: %6$d\n예상 가입: %7$d\n예상 탈퇴: %8$d\n선택 용량: %9$d KiB\n캡처 범위: %10$s\n\n아직 데이터는 변경되지 않았습니다. 가져오기 전에 자동 체크포인트를 생성합니다. 선택한 서클이 변경되었거나 더 이상 존재하지 않습니다. CSV 데이터는 가져오지 않았습니다. 적용할 서클을 다시 선택한 뒤 시도하세요. %1$s — %2$s CSV 파일을 검증하고 미리보기를 준비하는 중… @@ -435,7 +355,6 @@ 표 히스토리 자동으로 기록된 현재 주간 테이블의 과거 히스토리 목록입니다. 아직 기록된 주간 테이블 변경 이력이 없습니다. - 멤버 %1$d명 현재 복원된 항목 표 히스토리 미리보기 이 표 복원 @@ -452,10 +371,6 @@ 서클 이름 서클 ID 모든 항목을 확인하세요. 같은 클라이언트, 서버 및 서클 ID가 이미 등록되어 있을 수 있습니다. - 프로필 잊기 - 프로필 수용량을 확보하기 위해 이 서클을 선택 목록에서 제거할까요? 비공개 데이터는 유지되며, 해당 서클을 다시 감지하거나 복원하면 돌아옵니다. - 잊기 - 이 서클 프로필을 제거할 수 없습니다 감지된 서클 없음 아직 수집된 서클 정보가 없습니다. HaoPlay 캡처 서버 diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 74dce0f..1fb821a 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -32,8 +32,4 @@ #C77F82 #2F181A #D99396 - #2B392D - #A9C9A8 - #40362A - #D9B98D diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 44fda29..7e02b15 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -33,8 +33,4 @@ #D98A8A #C77777 #321A1B - #E2EBDD - #3F6847 - #F3E6CE - #806039 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f8ba43..76b759f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,43 +1,27 @@ GF2logger - Capture workspace Data tools Capture status Ready Capturing - Capture is stopped - Supported client IDs - Package ID - Package ID - com.haoplay.game.and.exilium\nHaoPlay client\n\ncom.Sunborn.SnqxExilium.Glo\nDarkwinter client Home Platoon Weekly - View all - On-device protocol capture for installed HaoPlay and Darkwinter clients. Raw traffic is not stored. Continuous One-time capture - %1$s Platoon identity\n%2$s Platoon roster\n%3$s Activity evidence\n%4$s Membership updates Stop Open payload options Export Platoon backup - Import Platoon backup - This replaces management data for the Platoon contained in the backup. Other Platoons are unchanged. Capture must remain stopped. Stop capture before exporting or importing a backup. Platoon backup exported - Platoon backup restored - Unable to export or restore the Platoon backup - This is not a valid Platoon backup file. Language English - Korean 한국어 Appearance System Light Dark - Use the device theme, or keep GF2logger in light or dark mode. Skip Back Next @@ -73,10 +57,8 @@ Use the information in each packet to complete member records and the weekly table over time. Backup Restore backup - Restore app settings and all management data for the Platoon contained in a complete backup. Other Platoons remain unchanged. Back up all information Save app settings and all management data for the selected Platoon, including membership history and weekly tables. - This replaces app settings and data for the Platoon contained in the backup, then selects it. Other Platoons remain unchanged. Keep capture stopped while restoring. This is not a valid complete backup file. Complete backup saved Complete backup restored @@ -87,18 +69,15 @@ Weekly task table Compare latest snapshots Export selected members - Select %1$s Select one or more members first Selected members exported Search name or UID - Active + Withdrawn Active Name Merit This Week Total Merit Last login No Platoon Members snapshot has been captured yet. - %1$d active · %2$d withdrawn\nLast snapshot: %3$s (%4$s) No matching members Merit This Week Total Merit @@ -107,15 +86,11 @@ %1$s → %2$s Joined Withdrew - Member changes Gunsmoke Frontline score None Member details Active member Withdrawn member - #%1$d · %2$s - Member name - Note Nickname Personal notes Save @@ -129,22 +104,15 @@ Member deleted Unable to delete member Manually Add Withdrawn Member - Join: - Withdraw: Notes - Not set Select date Time (optional) - Select a date first Clear date Clear time UID Add - Clear Enter a unique positive UID and nickname, select both dates, and keep withdrawal after join. - Membership period %1$d\nJoin: %2$s\nWithdrawal: %3$s Period %1$d - %1$s → %2$s Delete membership period Delete this join and withdrawal record? This cannot be undone. Continue? Membership period deleted @@ -152,26 +120,17 @@ The only membership period cannot be deleted. Use Delete member to remove this record. Unknown Edit membership dates - Join (yyyy-MM-dd HH:mm), blank if unknown - Withdrawal (yyyy-MM-dd HH:mm), blank if active/unknown Select a join date and keep withdrawal after join; times are optional Gunsmoke Frontline week Standard week - %1$s – %2$s · reset 05:00 (%3$s) Previous week - Current week Next week Loading weekly table… Unable to load the weekly table. Retry Capture the Platoon Members page on two different game days, or import roster CSV files, to build this week\'s table. Member - Rank Total - %1$d merit\nLogin %2$s · Patrol %3$s - %1$d merit\n%2$d pt · %3$s attempts\nLogin %4$s · Patrol %5$s - %1$d merit - %1$d merit\n%2$d pt · %3$s attempts Notes Join / Withdraw Join @@ -181,28 +140,18 @@ Add note This week already contains the maximum 128 notes. Delete note - Delete this note? Delete Note deleted Export weekly CSV Weekly CSV exported Export all weekly task tables No weekly task tables are available to export. - Join - Join - Withdraw Settings Choose which decoded payloads are added to future packet history. Existing history is unchanged. - This choice also applies to packet and report labels. - Follow the device, or keep GF2logger in Light or Dark mode. - Game timezone - Daily reset time - Choose the game server region. The next 05:00 server reset is converted to this phone’s timezone. Server region Auto select Not selected — choose before first capture Auto · %1$s - Auto select is available after a platoon profile is detected. Manual timezone Darkwinter Global Darkwinter China @@ -221,11 +170,8 @@ Canceling server selection may prevent these packets from being saved. Discard this unconfirmed Platoon capture? Manage Platoons Edit a detected Platoon’s server or permanently delete its isolated data. - %1$s · %2$s · ID %3$d Active - Edit client/server Edit server for %1$s - Verified client: %1$s (read-only)\nChoose the server for %2$s. Stop capture before editing or deleting a Platoon. Unable to update the Platoon server. No data was moved. Delete Platoon @@ -236,13 +182,10 @@ 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) - Game: %1$s · phone: %2$s Game timezone updated and weekly history rebuilt Unable to complete the timezone change and weekly-history rebuild Move complete app data only through a backup you explicitly choose. Back - %1$s (payloadType = %2$d) - Required and always enabled. %1$s %1$s, payload type %2$s unknown Platoon members @@ -268,28 +211,17 @@ Owned weapon mods, levels, suit flags, and lock state. Owned public skill items. The id is an inventory-instance ID, not a player UID. Saved team formations and their equipped items. - Recent parsed packets (latest %1$d) - Delete selected history - Save selected history - Saved packets (up to %1$d) - Delete selected saved history Export latest roster CSV Import roster CSV files - CSV import complete: %1$d retained, %2$d current, %3$d historical, %4$d skipped The selected Platoon CSV files could not be imported No parsed packets captured yet No packets saved yet - Select %1$s - Show raw CSV file Copy parsed packet GF2logger parsed packet Parsed packet copied Preparing capture - Exported %1$s Unable to export Platoon CSV No Platoon CSV has been captured yet - Select one or more history entries first - Select one or more saved entries first Unable to save selected history Selected history is already saved Saved-history limit reached (%1$d) @@ -311,7 +243,6 @@ Weekly Daily Patrol days Minimum Save settings - Enter valid non-negative cutline values. Member order Weekly tables list members in this order. Drag a handle to fine-tune it; changes save automatically. Sort by @@ -343,10 +274,6 @@ Confirm Cancel Weekly task table edits saved - Enter non-negative numbers. Gunsmoke attempts must be between 0 and 3. - ≥ marks the minimum confirmed by captures so far on an unfinished day.\n? means the available captures cannot determine that day.\nCompleted days with multiple compatible 0/50/90 allocations remain unknown; the app never displays an arbitrary estimate.\nIf the skipped period is excessively long, all earlier history may be displayed as ?. - ≥ marks a confirmed minimum; ? means the available captures cannot safely separate that day.\nMissing Gunsmoke days cannot be safely divided into merit, score, attempts, attendance, or Daily Patrol; use the counter-backed Total and manually correct only facts you know.\nIf the skipped period is excessively long, all earlier history may be displayed as ?. - observed %1$s Deleted %1$d history entry Deleted %1$d history entries @@ -363,18 +290,13 @@ Deleted %1$d saved entry Deleted %1$d saved entries - Evidence Health How evidence states work - ✓ exact · ≥ confirmed minimum · ? unknown — tap for details An exact value is confirmed by a closing boundary, direct event, manual edit, or unanimous valid allocation. Captures prove at least this value, but missing evidence may make the final value higher. The available captures cannot determine this field safely, so GF2logger does not guess. Completed days can only use valid daily merit allocations. If several 0/50/90 combinations still fit, the day remains unknown. The counter-backed weekly total remains useful when missing Gunsmoke days cannot be divided safely. Long capture gaps can leave earlier fields unknown. Tap any weekly cell to inspect the exact evidence used. - Observed days %1$d/%2$d · Exact %3$d · Minimum %4$d · Unknown %5$d\nDirect login %6$d days · Direct patrol %7$d days · Closing boundaries %8$d - %1$s · %2$s · %3$s - Answer: %1$s\n\nWhy:\n%2$s Confirmed Confirmed minimum; the final value may be higher Unknown from the available captures @@ -394,8 +316,6 @@ A long gap prevents a unique daily allocation. More than one valid allocation remains, so the app does not guess. Review CSV import - Destination: %1$s (ID %2$d)\n%3$s · %4$s\n\nPayload 21917 cannot verify this destination. Confirm that the selected Platoon is correct. - Validated files: %1$d\nAlready present: %2$d\nHistorical captures: %3$d\nMembers represented: %4$d\nNew member UIDs: %5$d\nName differences: %6$d\nPotential joins: %7$d\nPotential withdrawals: %8$d\nSelected size: %9$d KiB\nCapture range: %10$s\n\nNo data has been changed. Importing creates an automatic checkpoint first. The selected Platoon changed or is no longer available. No CSV data was imported. Select the destination Platoon and try again. %1$s — %2$s Validating CSV files and preparing preview… @@ -439,7 +359,6 @@ Table History Automatically recorded past versions of the current weekly table. No table changes have been recorded yet. - %1$d members Currently restored Table history preview Restore this table @@ -456,10 +375,6 @@ Platoon name Platoon ID Check every field. This client, server, and Platoon ID may already be registered. - Forget profile - Remove this platoon from the selectors to recover profile capacity? Its private data is retained and returns if the platoon is detected or restored again. - Forget - Unable to forget this platoon profile No platoon detected No Platoon information has been collected yet. HaoPlay capture server 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 fac34aa..50d1584 100644 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt @@ -101,14 +101,4 @@ class CaptureStatusTest { CaptureStatus.markStopped() assertEquals(2, received.size) } - - @Test - @Suppress("DEPRECATION") - fun deprecatedReadKeepsTheLegacySentinels() { - assertEquals("Capture is stopped", CaptureStatus.read()) - CaptureStatus.markStarting() - assertEquals("Preparing capture", CaptureStatus.read()) - CaptureStatus.markRunning(listOf("a", "b")) - assertTrue(CaptureStatus.read().startsWith("Capturing only ")) - } } From a527f494aab12b562a009aed6a8592c348d7a37f Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:34:32 +0900 Subject: [PATCH 031/125] fix: polish member detail, inputs, and metric labels Keep string line breaks escaped, size inputs to the body type scale, use a plain add icon, show first-seen date instead of repeating the member name, and spell out score and attempt labels. --- .../java/dev/gf2log/app/MemberDetailActivity.kt | 7 +++++-- .../java/dev/gf2log/app/PlatoonCsvImportPrompt.kt | 2 +- app/src/main/java/dev/gf2log/app/ui/Views.kt | 1 + app/src/main/res/drawable/ic_add.xml | 9 +++++++++ app/src/main/res/drawable/ic_add_circle.xml | 13 ------------- app/src/main/res/values-ko/strings.xml | 4 ++-- app/src/main/res/values/strings.xml | 8 ++++---- 7 files changed, 22 insertions(+), 22 deletions(-) create mode 100644 app/src/main/res/drawable/ic_add.xml delete mode 100644 app/src/main/res/drawable/ic_add_circle.xml diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 05924a3..ae08a2a 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -121,7 +121,7 @@ class MemberDetailActivity : BaseActivity() { }, lp()) addView(row { addView(sectionHeader(getString(R.string.membership_history)), lp(width = 0, weight = 1f)) - addView(button(getString(R.string.add_membership_history_short), Controls.Role.TERTIARY, R.drawable.ic_add_circle) { + addView(button(getString(R.string.add_membership_history_short), Controls.Role.TERTIARY, R.drawable.ic_add) { addMembershipPeriod() }, lp(WRAP, WRAP, top = 8)) }, lp()) @@ -170,7 +170,7 @@ class MemberDetailActivity : BaseActivity() { val status = loaded.status screen.topBar.setTitle(status.name, "#${status.uid}") statusPanel.addView(row { - addView(text(status.name, TextRole.TITLE), lp(width = 0, weight = 1f)) + addView(text(getString(R.string.member_since, firstSeen(status, loaded.zone)), TextRole.SECONDARY), lp(width = 0, weight = 1f)) addView(badge( getString(if (status.isActive) R.string.active_member else R.string.departed_member), if (status.isActive) R.color.success_surface else R.color.surface_variant, @@ -401,6 +401,9 @@ class MemberDetailActivity : BaseActivity() { } } + private fun firstSeen(status: MemberStatus, zone: ZoneId): String = + Labels.format(Labels.DATE, status.firstSeenAt.atZone(zone).toLocalDate()) + private fun sourceLabel(source: EvidenceSource): String = getString( when (source) { EvidenceSource.GAME_UPDATES -> R.string.source_updates diff --git a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt index fdddc3d..5fa9fab 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt @@ -47,7 +47,7 @@ internal object PlatoonCsvImportPrompt { } else { addView(choices, activity.lp(top = 8)) } - addView(activity.button(activity.getString(R.string.add_new_platoon_profile), Controls.Role.TERTIARY, R.drawable.ic_add_circle) { + addView(activity.button(activity.getString(R.string.add_new_platoon_profile), Controls.Role.TERTIARY, R.drawable.ic_add) { showCreate(activity) { created -> dialog.dismiss() showSelector(activity, created.storageId, onSelected) 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 e05c6e0..b4a93f0 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Views.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -176,6 +176,7 @@ internal fun Context.textField( ): EditText = EditText(this).apply { if (id != View.NO_ID) this.id = id hint = hintText + textSize = 15f this.inputType = if (singleLine) inputType else inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE if (singleLine) setSingleLine(true) else minLines = 2 maxLength?.let { filters = arrayOf(InputFilter.LengthFilter(it)) } diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..5d03bbe --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_circle.xml b/app/src/main/res/drawable/ic_add_circle.xml deleted file mode 100644 index 9c4919a..0000000 --- a/app/src/main/res/drawable/ic_add_circle.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index dc84327..b1405e1 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -409,8 +409,7 @@ 이 기기에만 보관됩니다. 직접 포함하지 않으면 공유 이미지에 나타나지 않습니다. 이 멤버에 대한 개인 메모 프로필 - %1$s → %2$s -가입: %3$s · 탈퇴: %4$s + %1$s → %2$s\n가입: %3$s · 탈퇴: %4$s 다른 이력과 겹치거나 열린 이력이 둘이 됩니다. 저장할 수 없습니다. 이력은 겹칠 수 없으며 열린 이력은 하나만 가능합니다. 이번 주 @@ -577,4 +576,5 @@ 패킷 %1$d개를 삭제할까요? 되돌릴 수 없습니다. + %1$s 처음 확인 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76b759f..e78c370 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -263,8 +263,8 @@ GF2logger weekly report CSV Weekly CSV copied Merit - Point - Try + Score + Attempts Login Patrol Manually edit weekly task table @@ -413,8 +413,7 @@ Private to this device. Excluded from shared images unless you include it. Personal notes about this member Profile - %1$s → %2$s -Join: %3$s · Withdraw: %4$s + %1$s → %2$s\nJoin: %3$s · Withdraw: %4$s This period overlaps another period or leaves two periods open. Unable to save. Periods cannot overlap, and only one can stay open. This week @@ -585,4 +584,5 @@ Join: %3$s · Withdraw: %4$s Delete %1$d packet? This cannot be undone. Delete %1$d packets? This cannot be undone. + First seen %1$s From c49d0acae1ca8e0a681bb940c5f84f54c317699b Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:34:39 +0900 Subject: [PATCH 032/125] refactor: share merit rules and solver helpers --- .../app/management/ActivityInference.kt | 9 +++- .../app/management/GunsmokeWeekSolver.kt | 47 ++++--------------- .../gf2log/app/management/PlatoonModels.kt | 6 +++ .../app/management/StandardWeekSolver.kt | 26 ++++------ .../app/management/WeeklyReportBuilder.kt | 16 ++----- .../app/management/GunsmokeWeekSolverTest.kt | 2 +- 6 files changed, 35 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt b/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt index 9a1bdab..8fada47 100644 --- a/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt +++ b/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt @@ -171,13 +171,13 @@ object ActivityInference { ): Boolean = score / attempts > maximumAttemptScore || (score / attempts == maximumAttemptScore && score % attempts != 0L) - private fun saturatedMultiply(value: Long, factor: Int): Long = when { + fun saturatedMultiply(value: Long, factor: Int): Long = when { factor == 0 -> 0L value > Long.MAX_VALUE / factor -> Long.MAX_VALUE else -> value * factor } - private fun saturatedAdd(left: Long, right: Long): Long = + fun saturatedAdd(left: Long, right: Long): Long = if (left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right fun counterDelta(previous: Long, current: Long): Long = @@ -221,6 +221,11 @@ object ActivityInference { val dailyPatrol: Boolean, ) + /** Daily non-Gunsmoke merit: login attendance, and Daily Patrol on top of it. */ + const val LOGIN_MERIT = 50L + const val DAILY_PATROL_MERIT = 40L + const val LOGIN_AND_PATROL_MERIT = LOGIN_MERIT + DAILY_PATROL_MERIT + const val MAX_DAILY_ATTEMPTS = 3 const val MAX_WEEKLY_ATTEMPTS = MAX_DAILY_ATTEMPTS * 7 const val MERIT_PER_ATTEMPT = 30L 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 aefd267..a7ed44b 100644 --- a/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt @@ -1,5 +1,7 @@ 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 @@ -15,22 +17,6 @@ import java.time.ZoneId * usable evidence. Only values shared by every compatible path are published. */ internal object GunsmokeWeekSolver { - fun solve( - uid: Long, - days: List, - zoneId: ZoneId, - snapshots: List, - cells: List, - dailyPatrolFacts: List, - ): List = resolve( - uid, - days, - zoneId, - snapshots, - cells, - dailyPatrolFacts, - ).cells - // Function Name: resolve // Description: // - Solves daily Gunsmoke states and preserves whole-week totals shared by every valid path. @@ -1000,15 +986,6 @@ internal object GunsmokeWeekSolver { Long.MAX_VALUE } - private fun saturatedMultiply(value: Long, factor: Int): Long = when { - factor == 0 -> 0L - value > Long.MAX_VALUE / factor -> Long.MAX_VALUE - else -> value * factor - } - - private fun saturatedAdd(left: Long, right: Long): Long = - if (left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right - private fun matchesFinalEvidence( state: ActivityState, cell: WeeklyReportBuilder.DayCell, @@ -1127,8 +1104,6 @@ internal object GunsmokeWeekSolver { } } - private fun PlatoonSnapshot.member(uid: Long): SnapshotMember? = members.firstOrNull { it.uid == uid } - private fun SnapshotMember.hasValidActivityCounters(): Boolean = weeklyMerit >= 0L && totalMerit >= 0L && highScore >= 0L && totalScore >= 0L @@ -1201,13 +1176,13 @@ internal object GunsmokeWeekSolver { val maximumPatrolDays: Int, ) { val meritCertainty: MetricCertainty - get() = certainty(minimumMerit, maximumMerit) + get() = rangeCertainty(minimumMerit, maximumMerit) val attemptsCertainty: MetricCertainty - get() = certainty(minimumAttempts, maximumAttempts) + get() = rangeCertainty(minimumAttempts, maximumAttempts) val loginDaysCertainty: MetricCertainty - get() = certainty(minimumLoginDays, maximumLoginDays) + get() = rangeCertainty(minimumLoginDays, maximumLoginDays) val patrolDaysCertainty: MetricCertainty - get() = certainty(minimumPatrolDays, maximumPatrolDays) + get() = rangeCertainty(minimumPatrolDays, maximumPatrolDays) operator fun plus(state: ActivityState): ActivityTotalBounds = ActivityTotalBounds( minimumMerit = minimumMerit + state.merit, @@ -1243,12 +1218,6 @@ internal object GunsmokeWeekSolver { maximumPatrolDays = 0, ) - private fun certainty(minimum: Number, maximum: Number): MetricCertainty = - if (minimum.toLong() == maximum.toLong()) { - MetricCertainty.EXACT - } else { - MetricCertainty.LOWER_BOUND - } } } @@ -1382,8 +1351,8 @@ internal object GunsmokeWeekSolver { private const val MAX_PARTIAL_SOLUTIONS = 2_000 private const val MAX_SEARCH_OPERATIONS = 100_000 private const val MAX_AGGREGATE_SEARCH_OPERATIONS = 100_000 - private const val LOGIN_MERIT = 50L - private const val PATROL_MERIT = 40L + private const val LOGIN_MERIT = ActivityInference.LOGIN_MERIT + private const val PATROL_MERIT = ActivityInference.DAILY_PATROL_MERIT private const val MAX_PRE_GUNSMOKE_WEEKLY_MERIT = 6L * (LOGIN_MERIT + PATROL_MERIT) private const val DAILY_BASELINE_AND_ATTEMPT_MERIT = LOGIN_MERIT + PATROL_MERIT + 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 cf78876..a046535 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt @@ -11,6 +11,8 @@ data class PlatoonSnapshot( val gameVersion: String? = null, ) +internal fun PlatoonSnapshot.member(uid: Long): SnapshotMember? = members.firstOrNull { it.uid == uid } + data class SnapshotMember( val uid: Long, val name: String, @@ -150,6 +152,10 @@ enum class MetricCertainty { EXACT, } +/** EXACT when a value's bounds coincide, otherwise only its minimum is known. */ +internal fun rangeCertainty(minimum: Number, maximum: Number): MetricCertainty = + if (minimum.toLong() == maximum.toLong()) MetricCertainty.EXACT else MetricCertainty.LOWER_BOUND + data class WeeklyNote( val id: Long, val periodStart: LocalDate, 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 9412a97..32ed303 100644 --- a/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt @@ -251,21 +251,14 @@ object StandardWeekSolver { val maximumPatrol = maximumPatrolTotals.maxOrNull() ?: return null return WeeklyReportBuilder.ResolvedStandardTotals( merit = minimumMerit, - meritCertainty = certainty(minimumMerit, maximumMerit), + meritCertainty = rangeCertainty(minimumMerit, maximumMerit), loginDays = minimumLogin, - loginDaysCertainty = certainty(minimumLogin, maximumLogin), + loginDaysCertainty = rangeCertainty(minimumLogin, maximumLogin), patrolDays = minimumPatrol, - patrolDaysCertainty = certainty(minimumPatrol, maximumPatrol), + patrolDaysCertainty = rangeCertainty(minimumPatrol, maximumPatrol), ) } - private fun certainty(minimum: Number, maximum: Number): MetricCertainty = - if (minimum.toLong() == maximum.toLong()) { - MetricCertainty.EXACT - } else { - MetricCertainty.LOWER_BOUND - } - private fun compatibleAllocations( indexes: List, days: List, @@ -341,9 +334,6 @@ object StandardWeekSolver { return stage.takeIf { loginStageCompatible } } - private fun PlatoonSnapshot.member(uid: Long): SnapshotMember? = - members.firstOrNull { it.uid == uid } - private data class CounterObservation( val capturedAt: Instant, val gameDay: LocalDate, @@ -351,9 +341,9 @@ object StandardWeekSolver { val lastLoginDay: LocalDate?, ) - private const val MAX_DAILY_MERIT = 90L - private const val LOGIN_MERIT = 50L - private const val PATROL_MERIT = 90L - private val DAILY_MERIT = longArrayOf(90L, 50L, 0L) - private val DAILY_STAGE = setOf(0L, 50L, 90L) + private const val MAX_DAILY_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT + private const val LOGIN_MERIT = ActivityInference.LOGIN_MERIT + private const val PATROL_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT + private val DAILY_MERIT = longArrayOf(PATROL_MERIT, LOGIN_MERIT, 0L) + private val DAILY_STAGE = setOf(0L, LOGIN_MERIT, PATROL_MERIT) } 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 2e8f1af..f463ac2 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt @@ -634,9 +634,9 @@ object WeeklyReportBuilder { scoreDelta = if (isGunsmoke) null else 0L } else { val lastKnown = after ?: before - meritDelta = counterDelta(lastKnown.totalMerit, before.totalMerit) + meritDelta = ActivityInference.counterDelta(before.totalMerit, lastKnown.totalMerit) scoreDelta = if (isGunsmoke) { - counterDelta(lastKnown.totalScore, before.totalScore) + ActivityInference.counterDelta(before.totalScore, lastKnown.totalScore) } else { 0L } @@ -742,15 +742,9 @@ object WeeklyReportBuilder { } - private fun PlatoonSnapshot.member(uid: Long): SnapshotMember? = - members.firstOrNull { it.uid == uid } - - private fun counterDelta(current: Long, previous: Long): Long = - if (current >= previous) current - previous else current private const val BOUNDARY_WINDOW_MINUTES = 15L - private const val MAX_STANDARD_DAILY_MERIT = 90L - private const val MAX_STANDARD_WEEKLY_MERIT = MAX_STANDARD_DAILY_MERIT * 7 - private const val STANDARD_LOGIN_MERIT = 50L - private const val STANDARD_PATROL_MERIT = 90L + private const val MAX_STANDARD_WEEKLY_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT * 7 + private const val STANDARD_LOGIN_MERIT = ActivityInference.LOGIN_MERIT + private const val STANDARD_PATROL_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT } 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 d3d03ae..4b6eeea 100644 --- a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt @@ -513,7 +513,7 @@ class GunsmokeWeekSolverTest { } } - return GunsmokeWeekSolver.solve(UID, days, zone, snapshots, cells, facts) + return GunsmokeWeekSolver.resolve(UID, days, zone, snapshots, cells, facts).cells } private fun member( From a3f9f54a06fca485bbc426e8d92e698ce81799d6 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:34:39 +0900 Subject: [PATCH 033/125] refactor: drop dead certainty branch and qualified time types --- .../dev/gf2log/app/management/BackupArchive.kt | 3 ++- .../dev/gf2log/app/management/PlatoonMemberCsv.kt | 6 ++++-- .../app/management/WeeklyEvidenceAnalyzer.kt | 15 ++++++--------- .../dev/gf2log/app/settings/AppBackupSettings.kt | 5 +++-- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt index 1d19498..6f4e8b5 100644 --- a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt +++ b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt @@ -9,6 +9,7 @@ import java.io.File import java.io.FilterOutputStream import java.io.InputStream import java.io.OutputStream +import java.time.Instant import java.util.Properties import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @@ -211,7 +212,7 @@ internal object BackupArchive { platoonName = platoonName, bannerFrameId = bannerFrameId, bannerMarkId = bannerMarkId, - lastSeenAt = java.time.Instant.now(), + lastSeenAt = Instant.now(), legacy = legacy, ) } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt index 93a5910..b974417 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt @@ -1,6 +1,8 @@ package dev.gf2log.app.management import dev.gf2log.protocol.CsvCell +import java.time.Instant +import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -52,8 +54,8 @@ object PlatoonMemberCsv { }.trimEnd() + "\n" private fun formatBoundary( - date: java.time.LocalDate?, - instant: java.time.Instant?, + date: LocalDate?, + instant: Instant?, timeKnown: Boolean, zoneId: ZoneId, ): String { diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt index 27aea9f..a41ad02 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyEvidenceAnalyzer.kt @@ -127,8 +127,8 @@ object WeeklyEvidenceAnalyzer { val certainties = cells.flatMap { cell -> buildList { add(cell.meritCertainty) - add(cell.attended.toCertainty(cell.observed)) - add(cell.dailyPatrol.toCertainty(cell.observed)) + add(cell.attended.toCertainty()) + add(cell.dailyPatrol.toCertainty()) if (report.isGunsmokeWeek) { add(cell.scoreCertainty) add(cell.attemptsCertainty) @@ -162,8 +162,8 @@ object WeeklyEvidenceAnalyzer { Metric.MERIT -> cell.meritCertainty Metric.SCORE -> cell.scoreCertainty Metric.ATTEMPTS -> cell.attemptsCertainty - Metric.LOGIN -> cell.attended.toCertainty(cell.observed) - Metric.DAILY_PATROL -> cell.dailyPatrol.toCertainty(cell.observed) + Metric.LOGIN -> cell.attended.toCertainty() + Metric.DAILY_PATROL -> cell.dailyPatrol.toCertainty() } private fun totalCertainty( @@ -193,9 +193,6 @@ object WeeklyEvidenceAnalyzer { Metric.DAILY_PATROL -> dailyPatrol != null } - private fun Boolean?.toCertainty(observed: Boolean): MetricCertainty = when { - this != null -> MetricCertainty.EXACT - observed -> MetricCertainty.UNKNOWN - else -> MetricCertainty.UNKNOWN - } + private fun Boolean?.toCertainty(): MetricCertainty = + if (this != null) MetricCertainty.EXACT else MetricCertainty.UNKNOWN } diff --git a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt index 1b93d69..aae4180 100644 --- a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt +++ b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt @@ -4,6 +4,7 @@ import dev.gf2log.app.StrictProperties import dev.gf2log.protocol.PayloadCatalog import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.time.ZoneId import java.util.Properties data class AppBackupSettings( @@ -87,7 +88,7 @@ object AppBackupSettingsCodec { gameTimeZoneId = if (schemaVersion >= TIME_ZONE_SCHEMA_VERSION) { properties.required(KEY_GAME_TIME_ZONE) } else { - java.time.ZoneId.systemDefault().id + ZoneId.systemDefault().id }, onboardingCompleted = if (schemaVersion >= THEME_SCHEMA_VERSION) { properties.strictBoolean(KEY_ONBOARDING_COMPLETED) @@ -128,7 +129,7 @@ object AppBackupSettingsCodec { GameServerRegion.fromStored(settings.gameServerRegion).storedValue == settings.gameServerRegion, ) { "Unsupported game server region" } - require(runCatching { java.time.ZoneId.of(settings.gameTimeZoneId) }.isSuccess) { + require(runCatching { ZoneId.of(settings.gameTimeZoneId) }.isSuccess) { "Unsupported game timezone" } require( From bcd06a01a7ee43560e137be99af405b3874afeef Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:35:02 +0900 Subject: [PATCH 034/125] refactor: derive revision caps from the history codec limits --- .../dev/gf2log/app/management/PlatoonRepository.kt | 12 ++++++------ .../dev/gf2log/app/management/WeeklyNotePolicy.kt | 2 +- .../app/management/WeeklyReportHistoryCodec.kt | 12 ++++++------ 3 files changed, 13 insertions(+), 13 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 0c27bd2..d293738 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -619,12 +619,12 @@ internal class PlatoonRepository( companion object { const val RETAINED_CSV_DIRECTORY = "guild-members" - private const val MAX_REVISION_EVENTS = 512 - private const val MAX_REVISION_NOTES = 128 - private const val MAX_REVISION_NAMES = 768 - private const val MAX_REVISION_NAME_BYTES = 1_024 - private const val MAX_REVISION_NOTE_BYTES = 16 * 1_024 - private const val MAX_REVISION_PRIVATE_NOTE_BYTES = 2 * 1_024 + private const val MAX_REVISION_EVENTS = WeeklyReportHistoryCodec.MAX_EVENTS + private const val MAX_REVISION_NOTES = WeeklyReportHistoryCodec.MAX_NOTES + private const val MAX_REVISION_NAMES = WeeklyReportHistoryCodec.MAX_CONTEXT_MEMBERS + private const val MAX_REVISION_NAME_BYTES = WeeklyReportHistoryCodec.MAX_STRING_BYTES + private const val MAX_REVISION_NOTE_BYTES = WeeklyReportHistoryCodec.MAX_NOTE_BYTES + private const val MAX_REVISION_PRIVATE_NOTE_BYTES = WeeklyReportHistoryCodec.MAX_PRIVATE_NOTE_BYTES private const val TAG = "GF2PlatoonRepository" /* * Global lock order; acquire left to right and never in reverse: diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt index f5da951..27cac15 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt @@ -2,7 +2,7 @@ package dev.gf2log.app.management /** Keeps live weekly notes representable by the immutable weekly-history format. */ internal object WeeklyNotePolicy { - const val MAX_MANUAL_NOTES_PER_WEEK = 128 + const val MAX_MANUAL_NOTES_PER_WEEK = WeeklyReportHistoryCodec.MAX_NOTES fun canAdd(manualNoteCount: Int): Boolean = manualNoteCount in 0 until MAX_MANUAL_NOTES_PER_WEEK diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt index 6f628ae..0a2bd39 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt @@ -406,12 +406,12 @@ internal object WeeklyReportHistoryCodec { private const val FORMAT_VERSION = 2 private const val DAYS_PER_WEEK = 7 private const val MAX_MEMBERS = 256 - private const val MAX_CONTEXT_MEMBERS = 768 - private const val MAX_EVENTS = 512 - private const val MAX_NOTES = 128 - private const val MAX_STRING_BYTES = 1_024 - private const val MAX_NOTE_BYTES = 16 * 1_024 - private const val MAX_PRIVATE_NOTE_BYTES = 2 * 1_024 + const val MAX_CONTEXT_MEMBERS = 768 + const val MAX_EVENTS = 512 + const val MAX_NOTES = 128 + const val MAX_STRING_BYTES = 1_024 + const val MAX_NOTE_BYTES = 16 * 1_024 + const val MAX_PRIVATE_NOTE_BYTES = 2 * 1_024 private const val MAX_DECOMPRESSED_BYTES = 4 * 1024 * 1024 private const val STREAM_BUFFER_BYTES = 8 * 1024 const val MAX_PAYLOAD_BYTES = 2 * 1024 * 1024 From 12723c879c5cadebda8516640b90d6a7b407e60e Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:35:16 +0900 Subject: [PATCH 035/125] docs: describe the split database helper and active-state rule --- docs/ARCHITECTURE.md | 19 +++++++++++++------ docs/PLATOON_MANAGEMENT.md | 8 +++++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8e08656..f98e714 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -289,12 +289,19 @@ the native listener contract); encapsulation is provided by stores and the repository facade. Android lifecycle inheritance remains shallow through `LocalizedActivity`, `VpnService`, and `SQLiteOpenHelper`. -The large SQLite helper remains a maintenance boundary. Schema, migrations, -and transactional evidence correlation stay together to preserve atomic -ingestion. Read-only roster snapshot projection is extracted into -`WeeklySnapshotStore` behind the repository-facing database API; further -extractions must preserve the same single-transaction write owner and must not -let Activities issue SQL directly. +`PlatoonDatabase` is the single SQLite helper and transactional write owner of +one scope. Its SQL lives in package-internal objects that receive the open +database (`PlatoonDatabaseSchema`, `PlatoonDatabaseMigrations`, +`SnapshotIngestion`, `RosterHistoryReplay`, `ActivityIngestion`, +`ExactBoundaryCorrelation`, `MembershipEditing`, `MembershipRows`, +`PlatoonReadQueries`, `WeeklyAnnotationStore`, `WeeklyHistoryStore`, and +`WeeklySnapshotStore`), so one ingest still commits or rolls back as a whole. +Activities never issue SQL directly. + +Locks are always acquired in one order: a CSV checkpoint's per-scope claim, +the repository maintenance lock, the database helper, the profile registry, +then settings stores. Restore recovery runs only when durable restore artifacts +exist, so constructing a repository never takes the exclusive gate. ## Parsed-packet history diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 2485950..624a1d4 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -154,9 +154,11 @@ transitions. - Manual edits are accepted only when every period ends after it starts, no two fully dated periods overlap, and at most one period remains open. An absent snapshot-derived start remains unknown rather than being treated as an - infinitely old boundary. Current active state is - derived from that open period inside the same transaction; schema-v13 upgrade - repairs any stale derived flag left by an older installation. + infinitely old boundary. Every writer derives current active state from the + 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. - 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. From cc0c5845402bbb4bbf0cc35cf04e538a7729f722 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:36:27 +0900 Subject: [PATCH 036/125] docs: describe the v2.5.0 UI foundation and capture worker --- DESIGN.md | 9 ++++- PRODUCT.md | 7 ++-- docs/ARCHITECTURE.md | 70 +++++++++++++++++++++++++---------- docs/UI_DESIGN_SYSTEM.md | 80 +++++++++++++++++++++++++++------------- 4 files changed, 116 insertions(+), 50 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 96324c5..e945d7c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -119,7 +119,7 @@ The palette is warm, low-glare, and predominantly neutral; semantic color is nev ## Layout -Use a 4dp grid, a 16dp default screen inset, and 16-24dp between sections. Every actionable region is at least 48dp high even when its visible fill is inset. Home remains status-led; Settings uses grouped rows; onboarding keeps fixed top controls and bottom navigation around a flexible central explanation. Home, Platoon, and Weekly alone use the three-item bottom navigation. +Use a 4dp grid, a 16dp default screen inset, and 16-24dp between sections. Every actionable region is at least 48dp high even when its visible fill is inset. Content draws edge to edge with system-bar insets applied by the shared screen shell. Home remains status-led; Settings uses grouped rows and owns backup and restore; onboarding keeps fixed top controls and bottom navigation around a flexible central explanation. Home, Platoon, and Weekly alone use the three-item bottom navigation. ## Elevation & Depth @@ -159,7 +159,11 @@ Home, Platoon, and Weekly share a 64dp bottom bar. Each destination has a 24dp i ### Capture Status Panel -Capture state, two compact read-only supported-client package IDs, their concise help popover, and Start/One-time/Stop actions form one operational group. Start actions become unavailable while capture is preparing or active; Stop becomes unavailable while stopped. +Capture state, its localized latest notice, the one-time capture checklist, the two supported clients with their package IDs and install state, and the capture actions form one operational group. While stopped the panel offers One-time capture (primary) and Continuous capture; while preparing or capturing those are replaced by a single Stop action. + +### Weekly Table + +The member column stays frozen while the seven day columns and the total scroll horizontally as one grid under a sticky day header. Each cell shows merit, the Gunsmoke score and attempts when relevant, and Login/Patrol marks; tapping it explains every metric's evidence in one sheet. Edit mode edits one member-day at a time and highlights changes until they are saved. ### Onboarding @@ -170,6 +174,7 @@ Five dark editorial pages present the emblem, one concise topic, progress, three ### Do: - **Do** keep capture state and next actions visible without scrolling on the primary screen. +- **Do** move secondary utilities into the top-bar overflow menu instead of stacking full-width buttons. - **Do** preserve 48dp targets, native checked semantics, and Android Back behavior. - **Do** verify English, Korean, light/dark themes, system bars, and enlarged text on a physical device. - **Do** use orange sparingly for primary action and selection. diff --git a/PRODUCT.md b/PRODUCT.md index 300cda1..9933120 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -33,7 +33,8 @@ recoverable user-controlled backup and export. - Keep the existing Kotlin/Android Views architecture. Do not introduce a parallel UI framework or move repository, parser, capture, or report policy - into Activities. + into Activities. AndroidX Activity is the only UI library dependency; it + provides ComponentActivity, the Activity Result API, and edge-to-edge support. - Preserve English and Korean, System/Light/Dark appearance, Android Back behavior, private app storage, and explicit user-driven share/export. - Keep the three primary destinations clear: Home, Platoon, and Weekly. @@ -57,8 +58,8 @@ is the binding visual direction for the v2.3.0 frontend: stop, destructive, or failed states; - layouts are compact, status-led, flat, and separated with hairlines or tonal surfaces instead of nested oversized cards; -- Home uses a status dashboard, key Platoon/Weekly shortcuts, concise utilities, - recent evidence, and a three-destination bottom bar; +- Home uses a status dashboard, the active Platoon, concise data tools, recent + evidence, and a three-destination bottom bar; - Settings uses a compact top bar and grouped list rows; - onboarding uses an editorial dark composition with the emblem, concise feature guidance, visible progress, top-right Skip, and bottom navigation. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8e08656..6eb5044 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -112,7 +112,16 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. fragments through the logical terminal frame before later datasets are parsed. Quarantine is advanced from frame metadata before protobuf decoding, so even a malformed terminal fragment clears the discarded dataset deterministically. -- The parser service has one worker and a queue capped at 256 payload chunks. +- 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, + `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 + loop, drains already queued chunks, and clears per-run state; a start that + arrives during teardown waits for it to finish. - 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. @@ -124,6 +133,9 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. capture may admit at most one new profile per supported client. Explicit profile deletion removes one immutable scope behind two confirmations. - TLS, HTTP, and UDP payloads remain native and are excluded from the parser. + A TCP flow is classified once, on its first payload in either direction, so + a later plaintext segment that happens to begin with TLS-like bytes can never + disable inspection. - Outgoing plaintext chunks are used only for native flow classification. - Flow-close callbacks finalize any pending recognized payload before removing parser state and unconditionally remove metadata even when no parser existed. A rejected close callback @@ -233,29 +245,36 @@ plaintext TCP streams. Do not add pinning or anti-cheat bypasses. ## Display and first-launch boundary -`LocalizedActivity` applies both locale and System, Light, or Dark appearance -through a configuration-wrapped base context before an Activity creates its -views. `ModernUi` supplies shared presentation primitives only; it does not own -capture, parsing, persistence, or report policy. Theme-specific resources keep -packet tables, weekly grids, controls, and status surfaces readable without -duplicating domain logic in Activities. - -`PrimaryNavigation` is the presentation-only shell shared by Home, Platoon, and -Weekly. It launches existing Activities and owns no data or domain state. -Onboarding overrides only the resolved presentation theme so the approved dark +`BaseActivity` extends AndroidX `ComponentActivity`. It applies the persisted +language and System, Light, or Dark appearance through a configuration-wrapped +base context before views exist, draws edge to edge, and observes the pending +Platoon admission store so a newly detected Platoon is offered for confirmation +as soon as it appears. Screens are composed from the presentation-only +`dev.gf2log.app.ui` package (`Screen`, `TopBar`, `Controls`, `Views`, +`StateViews`, `Dialogs`); it owns no capture, parsing, persistence, or report +policy. `BackgroundWork` runs each screen's repository, file, and network calls +in submission order off the main thread and delivers results only while the +screen exists, with keyed requests superseding stale loads. + +Capture state reaches the UI as an immutable `CaptureSnapshot` (phase, latest +typed and localized notice, captured targets, one-time checklist) published by +`CaptureStatus` listeners on the main looper. Nothing in the UI parses status +text or polls. + +`PrimaryNavigation` provides the Home, Platoon, and Weekly bar used by `Screen`. +It launches existing Activities and owns no data or domain state. Onboarding +overrides only the resolved presentation theme so the approved dark walkthrough remains stable; its selected language still persists through the -same user-settings store as the rest of the app. +same user-settings store as the rest of the app, and Settings can replay it +without resetting the first-launch flag. The concrete presentation roles, warm light/dark palette, compact touch-target contract, and accessibility review gates are documented in -[`UI_DESIGN_SYSTEM.md`](UI_DESIGN_SYSTEM.md). Activities select semantic roles; -they do not define one-off button palettes or treat compound controls as generic -buttons. +[`UI_DESIGN_SYSTEM.md`](UI_DESIGN_SYSTEM.md). `MainActivity` routes an installation with no completed first-use flag to the non-exported `OnboardingActivity`. The five-page guide may be finished or -skipped, and writes completion only at that explicit exit. Its English/Korean -segmented control persists the same language preference used by Settings. +skipped, and writes completion only at that explicit exit. Complete backup settings schema v4 includes language, theme, onboarding completion, and the server-region reset preset. Historical manual timezone values remain decodable for backup compatibility but are no longer offered in @@ -308,7 +327,12 @@ 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. -The main activity renders both collections with timestamp-only titles in `yy/MM/dd HH:mm:ss` using the Android device timezone. Selecting a title opens a cleaned table parsed from the stored CSV body; the same screen can reveal the complete raw stored text and copy it to the clipboard. Android backup rules exclude all private files, including recent history, saved history, generated Platoon CSV files, and the structured management database. A user can separately invoke the explicit Platoon backup export, which contains parsed management data but never raw traffic. +Every store instance for the same directory shares one process-wide lock, and +only saving trims the recent collection; listing never deletes. Home renders +both collections as Recent and Saved tabs with timestamp-only titles in +`yy/MM/dd HH:mm:ss` using the Android device timezone. Selecting a title opens a +cleaned table parsed from the stored CSV body; the same screen switches to the +complete raw stored text and back, and can copy it to the clipboard. Android backup rules exclude all private files, including recent history, saved history, generated Platoon CSV files, and the structured management database. A user can separately invoke the explicit Platoon backup export, which contains parsed management data but never raw traffic. ## Explicit backup boundary @@ -386,11 +410,17 @@ packet, import, or manual override that changes the live fingerprint clears that selection. The history Activities can list, preview, and select revisions, but cannot mutate or delete individual history rows. +Manual weekly corrections are merged with stored overrides by +`WeeklyOverrideEditing`: only edited fields change, untouched fields keep their +stored override, and the complete override set for the week is replaced as one +unit. + `WeeklyReportActivity` loads one immutable projection on a serialized worker. A lifecycle-independent `WeeklyReportStateHolder` owns the selected date and generation token, rejects results superseded by navigation or lifecycle -changes, and survives Activity recreation. A bounded `RecyclerView` recycles -member rows instead of accumulating the entire nested table hierarchy. The +changes, and survives Activity recreation. One vertical `RecyclerView` hosts the +week navigator, a sticky day header, recycled member rows with a frozen member +column and synced horizontal scrolling, and the membership and note sections. The Gunsmoke solver merges equivalent partial histories instead of retaining every complete path and enforces deterministic state/operation budgets; evidence beyond those budgets stays conservative rather than blocking diff --git a/docs/UI_DESIGN_SYSTEM.md b/docs/UI_DESIGN_SYSTEM.md index 44a9c36..bd3070a 100644 --- a/docs/UI_DESIGN_SYSTEM.md +++ b/docs/UI_DESIGN_SYSTEM.md @@ -20,33 +20,58 @@ repository or policy APIs, while `ModernUi` owns reusable presentation roles. - Bold type is limited to screen, section, and item headings. Body copy uses the platform sans-serif; table-like data may use monospaced figures. +## Building blocks + +Screens are composed in code from the shared `dev.gf2log.app.ui` package. An +Activity chooses components and roles; it never defines colors, dp math, or +one-off drawables. + +| Component | Use | +| --- | --- | +| `Screen` | Edge-to-edge shell: top bar, replaceable body, optional Home/Platoon/Weekly bar. Applies status, navigation, cutout, and keyboard insets once. | +| `TopBar` | 56dp bar with optional back/close navigation, title and subtitle, up to two icon actions, and an overflow menu for secondary utilities. | +| `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. | +| `SingleViewAdapter` | Hosts headers, footers, and state views inside one `RecyclerView` without recycling them. | + ## Control roles -`ModernUi.ControlRole` is the source of truth for action hierarchy: +`Controls.Role` is the source of truth for action hierarchy: | Role | Use | | --- | --- | -| Primary | The single commitment action for the current screen | -| Capture | Start or prepare continuous capture; green state treatment | -| Feature | Key navigation to Platoon management or the weekly table; soft orange surface | -| Evidence | Compact date/time action in a packet-history row | -| Secondary | Ordinary bounded action on the current screen | -| Tertiary | Low-emphasis action such as Back, Skip, or a week arrow | -| Navigation | Neutral full-row navigation or selection | -| Selector | Compact active-Platoon selector with a persistent outline and accent pressed state | -| Destructive | Confirmed destructive operation | -| Destructive text | Less prominent destructive option separated from primary work | - -Buttons keep a 48dp touch target but use an inset 40dp visible surface. A -multi-line destination uses `ModernUi.actionRow`: a 56dp row with a title, -optional detail, and chevron instead of a tall button containing paragraphs. -Do not encode `CheckBox` or `RadioButton` as generic buttons. - -Language and theme selectors are a single 56dp rail, not a row of adjacent -buttons. The rail has a 16dp radius and 4dp padding on all four sides. One 48dp, -12dp-radius accent thumb moves between equal-width native radio choices. This -concentric geometry keeps the horizontal and vertical inset identical and -prevents selected and unselected options from developing mismatched seams. +| Primary | The single commitment action of a screen or panel | +| Capture | Start continuous capture; green readiness treatment | +| Secondary | Ordinary bounded action | +| Tertiary | Low-emphasis inline action such as Select, Show all, or This week | +| Destructive | Confirmed destructive operation, such as Stop | +| Destructive text | Less prominent destructive option inside a selection bar | + +Buttons keep a 48dp touch target around a 40dp visible surface. Multi-line +destinations use grouped navigation rows with a title, supporting line, and +chevron instead of tall buttons. Secondary utilities live in the top-bar +overflow menu rather than as stacked full-width buttons. + +Choices use one 56dp rail with a 16dp radius and 4dp inset; a 48dp, 12dp-radius +accent thumb moves between equal-width native radio buttons. Motion is skipped +when Android animations are disabled. + +## Interaction patterns + +- Lists enter selection mode on long-press or from the overflow menu. The top + 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. +- Every repository, file, and network call runs through `BackgroundWork`; the + screen shows a loading, empty, or failure state instead of blocking input. +- Text fields carry stable view IDs and Activities save transient choices, so + rotation, theme or language changes, and large-screen resizing keep drafts. +- 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. ## Layout and accessibility @@ -66,9 +91,13 @@ prevents selected and unselected options from developing mismatched seams. bottom navigation bar. Settings remains a secondary screen with a normal back action; deeper detail and edit screens continue to use Android Back. - Compact controls may look smaller, but their hit area remains at least 48dp. +- Content draws edge to edge; system-bar and keyboard insets are applied by + `Screen`, so no screen hides content under the status or navigation bar. - Text wraps when it carries meaning; only short button labels may ellipsize. -- Icon-only controls require an accessible name. Decorative icons beside text - are hidden from the accessibility tree. +- 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. - 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 @@ -81,7 +110,8 @@ prevents selected and unselected options from developing mismatched seams. Before adding or changing a screen: 1. Identify the one primary action and assign every other control a lower role. -2. Use semantic resource colors; do not add raw RGB values in Activities. +2. Use `ui` components and semantic resource colors; do not add raw RGB values, + `GradientDrawable`s, or dp math in Activities. 3. Verify English and Korean labels at the supported system font scales. 4. Check a dark and light physical-device render, including system bar contrast. 5. Confirm that presentation code does not acquire SQL, parser, inference, or From f181f412e28a45a6c5dda6792decd3d19121a80c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:37:33 +0900 Subject: [PATCH 037/125] feat: localize shared weekly images and auto-detect restored backups Settings restores either backup kind from one entry and reports which kind was applied; roster imports run inside the checkpoint's always-releasing import wrapper; shared weekly PNGs use the app language for every label. --- .../main/java/dev/gf2log/app/CsvImportFlow.kt | 19 ++----- .../java/dev/gf2log/app/OptionsActivity.kt | 27 ++++++--- .../java/dev/gf2log/app/WeeklyPngExport.kt | 15 +++++ .../dev/gf2log/app/WeeklyReportActivity.kt | 8 ++- .../app/management/WeeklyShareProjection.kt | 55 +++++++++++++------ app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 7 files changed, 90 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt index 8ed32b1..b2a17bc 100644 --- a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt +++ b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt @@ -266,27 +266,18 @@ internal class CsvImportFlow( task = { val directory = import.target.scope.retainedCsvDirectory(activity) val store = PlatoonCsvImportStore(directory) - val checkpoint = CsvImportCheckpointManager(activity, import.target.scope) - var checkpointCreated = false - runCatching { - checkpoint.create( - import.prepared.filterNot { it.fileName in import.duplicateFileNames }.mapTo(mutableSetOf()) { it.fileName }, - ) - checkpointCreated = true + val planned = import.prepared + .filterNot { it.fileName in import.duplicateFileNames } + .mapTo(mutableSetOf()) { it.fileName } + CsvImportCheckpointManager(activity, import.target.scope).runImport(planned) { var retained = 0 var duplicates = 0 import.prepared.forEach { prepared -> if (store.retain(prepared).duplicate) duplicates += 1 else retained += 1 } val imported = PlatoonRepository(activity, import.target.scope).reconcileRetainedCsvFiles(directory) - checkpoint.seal() Summary(retained, duplicates, imported) - }.recoverCatching { failure -> - if (checkpointCreated) { - runCatching(checkpoint::rollbackFailedImport).exceptionOrNull()?.let(failure::addSuppressed) - } - throw failure - }.getOrThrow() + } }, ) { result -> onProgress(null) diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 7ce6a8e..2e4ec78 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -20,6 +20,7 @@ import dev.gf2log.app.management.InvalidBackupException import dev.gf2log.app.management.PlatoonBackupManager import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.app.management.RestoredBackupKind import dev.gf2log.app.settings.CapturePreferences import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion @@ -64,7 +65,6 @@ class OptionsActivity : BaseActivity() { progress = R.string.backup_exporting, success = if (complete) R.string.full_backup_exported else R.string.status_backup_exported, failure = { R.string.full_backup_export_failed }, - reload = false, ) { DocumentAccess.openOutput(contentResolver, uri).use { output -> val manager = PlatoonBackupManager(this, scope) @@ -338,10 +338,25 @@ class OptionsActivity : BaseActivity() { Feedback.show(this, R.string.invalid_full_backup, long = true) return } - runBackup(R.string.backup_restoring, R.string.full_backup_restored, ::fullBackupRestoreFailureMessage, reload = true) { - DocumentAccess.openInput(contentResolver, uri).use { input -> - PlatoonBackupManager.restoreSelected(this, input, complete = true) - } + Feedback.show(this, R.string.backup_restoring) + work.run( + task = { + DocumentAccess.openInput(contentResolver, uri).use { input -> + PlatoonBackupManager.restoreSelected(this, input) + } + }, + ) { result -> + result.fold( + onSuccess = { kind -> + Feedback.show( + this, + if (kind == RestoredBackupKind.COMPLETE) R.string.full_backup_restored else R.string.status_backup_restored, + long = true, + ) + recreate() + }, + onFailure = { Feedback.show(this, fullBackupRestoreFailureMessage(it), long = true) }, + ) } } @@ -349,13 +364,11 @@ class OptionsActivity : BaseActivity() { progress: Int, success: Int, failure: (Throwable) -> Int, - reload: Boolean, operation: () -> Unit, ) { Feedback.show(this, progress) work.run(task = operation) { result -> Feedback.show(this, result.fold(onSuccess = { success }, onFailure = failure), long = true) - if (result.isSuccess && reload) recreate() } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt b/app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt index 8d43536..38153b9 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyPngExport.kt @@ -20,6 +20,21 @@ import java.util.UUID internal object WeeklyPngExport { fun directory(cacheDirectory: File): File = File(cacheDirectory, DIRECTORY_NAME) + /** Image labels in the app's active language. */ + fun labels(context: Context) = WeeklyShareProjection.Labels( + standardTitle = context.getString(R.string.png_title, context.getString(R.string.off_week)), + gunsmokeTitle = context.getString(R.string.png_title, context.getString(R.string.gunsmoke_week)), + member = context.getString(R.string.member), + total = context.getString(R.string.total), + privateNote = context.getString(R.string.member_private_note), + anonymousMember = { context.getString(R.string.png_anonymous_member, it) }, + merit = context.getString(R.string.merit_short), + score = context.getString(R.string.point_short), + attempts = context.getString(R.string.attempt_short), + login = context.getString(R.string.login_short), + patrol = context.getString(R.string.patrol_short), + ) + fun exportName(periodStart: LocalDate): String = "GF2logger-week-${periodStart.format(DATE)}.png" fun render(context: Context, document: WeeklyShareProjection.Document, periodStart: LocalDate): File { diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 72f611f..3c87532 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -770,7 +770,13 @@ class WeeklyReportActivity : BaseActivity() { task = { WeeklyPngExport.render( this, - WeeklyShareProjection.build(current.report, current.displayedMembers, current.memberNotesByUid, privacy), + WeeklyShareProjection.build( + current.report, + current.displayedMembers, + current.memberNotesByUid, + privacy, + WeeklyPngExport.labels(this), + ), current.report.periodStart, ) }, diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt index a40efe1..1fd447e 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt @@ -10,6 +10,21 @@ object WeeklyShareProjection { val includePrivateNotes: Boolean = false, ) + /** Every word that appears in the image; the app passes its active language. */ + data class Labels( + val standardTitle: String = "GF2logger \u2014 Standard week", + val gunsmokeTitle: String = "GF2logger \u2014 Gunsmoke week", + val member: String = "Member", + val total: String = "Total", + val privateNote: String = "Private note", + val anonymousMember: (Int) -> String = { "Member $it" }, + val merit: String = "M", + val score: String = "S", + val attempts: String = "A", + val login: String = "L", + val patrol: String = "P", + ) + data class Document( val title: String, val subtitle: String, @@ -31,27 +46,28 @@ object WeeklyShareProjection { displayedMembers: List, privateNotesByUid: Map, privacy: Privacy, + labels: Labels = Labels(), ): Document { val reportUids = report.members.mapTo(mutableSetOf()) { it.uid } require(displayedMembers.all { it.uid in reportUids }) val headers = buildList { - add("Member") + add(labels.member) addAll(report.days.map { it.format(DAY) }) - add("Total") - if (privacy.includePrivateNotes) add("Private note") + add(labels.total) + if (privacy.includePrivateNotes) add(labels.privateNote) } val rows = displayedMembers.mapIndexed { index, member -> Row( - member = memberLabel(member, index, privacy), - dailyCells = member.days.map { formatDaily(it, report.isGunsmokeWeek) }, - total = formatTotal(member, report.isGunsmokeWeek), + member = memberLabel(member, index, privacy, labels), + dailyCells = member.days.map { formatDaily(it, report.isGunsmokeWeek, labels) }, + total = formatTotal(member, report.isGunsmokeWeek, labels), privateNote = privateNotesByUid[member.uid] ?.take(MAX_PRIVATE_NOTE_CHARS) ?.takeIf { privacy.includePrivateNotes && it.isNotBlank() }, ) } return Document( - title = if (report.isGunsmokeWeek) "GF2logger \u2014 Gunsmoke week" else "GF2logger \u2014 Standard week", + title = if (report.isGunsmokeWeek) labels.gunsmokeTitle else labels.standardTitle, subtitle = report.periodStart.toString() + " \u2014 " + report.periodEnd.toString(), headers = headers, rows = rows, @@ -64,38 +80,41 @@ object WeeklyShareProjection { member: WeeklyReportBuilder.MemberRow, index: Int, privacy: Privacy, + labels: Labels, ): String = when { privacy.includeMemberNames && privacy.includeUids -> member.name.take(MAX_MEMBER_LABEL_CHARS) + " (#" + member.uid + ")" privacy.includeMemberNames -> member.name.take(MAX_MEMBER_LABEL_CHARS) privacy.includeUids -> "#" + member.uid - else -> "Member " + (index + 1) + else -> labels.anonymousMember(index + 1) } private fun formatDaily( cell: WeeklyReportBuilder.DayCell, gunsmoke: Boolean, + labels: Labels, ): String = buildList { - add("M " + WeeklyMetricPresentation.format(cell.meritDelta, cell.meritCertainty)) + add(labels.merit + " " + WeeklyMetricPresentation.format(cell.meritDelta, cell.meritCertainty)) if (gunsmoke) { - add("S " + WeeklyMetricPresentation.format(cell.scoreDelta, cell.scoreCertainty)) - add("A " + WeeklyMetricPresentation.format(cell.attempts, cell.attemptsCertainty)) + add(labels.score + " " + WeeklyMetricPresentation.format(cell.scoreDelta, cell.scoreCertainty)) + add(labels.attempts + " " + WeeklyMetricPresentation.format(cell.attempts, cell.attemptsCertainty)) } - add("L " + activityMark(cell.attended)) - add("P " + activityMark(cell.dailyPatrol)) + add(labels.login + " " + activityMark(cell.attended)) + add(labels.patrol + " " + activityMark(cell.dailyPatrol)) }.joinToString("\n") private fun formatTotal( member: WeeklyReportBuilder.MemberRow, gunsmoke: Boolean, + labels: Labels, ): String = buildList { - add("M " + WeeklyMetricPresentation.format(member.totalMerit, member.totalMeritCertainty)) + add(labels.merit + " " + WeeklyMetricPresentation.format(member.totalMerit, member.totalMeritCertainty)) if (gunsmoke) { - add("S " + WeeklyMetricPresentation.format(member.totalScore, member.totalScoreCertainty)) - add("A " + WeeklyMetricPresentation.format(member.totalAttempts, member.totalAttemptsCertainty)) + add(labels.score + " " + WeeklyMetricPresentation.format(member.totalScore, member.totalScoreCertainty)) + add(labels.attempts + " " + WeeklyMetricPresentation.format(member.totalAttempts, member.totalAttemptsCertainty)) } - add("L " + WeeklyMetricPresentation.format(member.loginDays, member.loginDaysCertainty)) - add("P " + WeeklyMetricPresentation.format(member.patrolDays, member.patrolDaysCertainty)) + add(labels.login + " " + WeeklyMetricPresentation.format(member.loginDays, member.loginDaysCertainty)) + add(labels.patrol + " " + WeeklyMetricPresentation.format(member.patrolDays, member.patrolDaysCertainty)) }.joinToString("\n") private fun activityMark(value: Boolean?): String = when (value) { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b1405e1..48b7700 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -577,4 +577,7 @@ 패킷 %1$d개를 삭제할까요? 되돌릴 수 없습니다. %1$s 처음 확인 + 서클 백업을 복원했습니다 + GF2logger — %1$s + 멤버 %1$d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e78c370..12871b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -585,4 +585,7 @@ Delete %1$d packets? This cannot be undone. First seen %1$s + Platoon backup restored + GF2logger — %1$s + Member %1$d From 0ca11bf8997ac091d00d85dcd479415b6991f9db Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:38:03 +0900 Subject: [PATCH 038/125] docs: release notes and version 2.5.0 --- CHANGELOG.md | 61 ++++++++++++++++++++++++++++++++++++++++++ README.md | 7 ++--- README_KR.md | 7 ++--- THIRD_PARTY_NOTICES.md | 6 +++++ app/build.gradle | 4 +-- 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd3e3d3..021899f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,67 @@ All notable changes to mobileGF2logger are documented here. +## 2.5.0 - 2026-10-15 + +### Added + +- Draw every screen edge to edge with system-bar, cutout, and keyboard insets, + and keep drafts, selections, and pending exports across rotation, theme or + language changes, and process recreation. +- Rebuild the weekly table around a frozen member column, synced horizontal day + columns, and a sticky day header; tapping a cell explains every metric of + that day in one sheet. +- Edit weekly cells one day at a time with tri-state Login/Patrol choices, + highlighted changes, an explicit cancel, and a discard confirmation. +- Add Platoon roster filters (Active, Withdrawn, All), a sort chooser, summary + tiles, and long-press selection for CSV export; add move up/down + accessibility actions to member ordering. +- Show capture notices in English and Korean, the install state of both + supported clients, and a live one-time-capture checklist without polling. +- Restore either backup kind from one Settings entry, which detects whether the + archive includes app settings; replay the welcome guide from Settings. +- Localize every label in shared weekly PNGs. + +### Changed + +- Move backups into Settings and keep Home focused on capture, the active + Platoon, data tools, and Recent/Saved packet history tabs. +- Move secondary weekly and Platoon utilities into top-bar overflow menus. +- Request the notification permission when capture starts instead of at launch. +- Render table-history previews as the native table instead of a bitmap. +- Split the management database into focused SQL objects behind the same + single transactional owner, and split the capture service into a serial + worker, flow engine, admission coordinator, and notification helper. +- Add AndroidX Activity as the only new dependency. + +### Fixed + +- Release native global references, join each capture thread exactly once, and + classify TLS/HTTP flows only on their first payload so plaintext game data + can no longer silently disable inspection. +- Stop capture without blocking the main thread, keep pending Platoon + confirm/discard working after capture stops, and remove cross-thread races in + capture state and status. +- Serialize parsed-packet history per directory, stop deleting files while + listing, and write each roster CSV once with distinct row counts. +- Keep unmergeable continuation payloads instead of silently dropping them. +- Recover CSV checkpoint restores into the Platoon's own settings, release + checkpoint claims on every failure, and remove a lock-order deadlock between + profile deletion recovery and backup restore. +- Compare restored schemas against an in-memory reference, run restore + recovery only when artifacts exist, and roll back partially replaced scoped + settings. +- Derive member active state from one canonical membership-period rule. +- Load member periods in one query, batch inferred boundary correlation, solve + only period-end roster rows, and refresh only weekly revisions a member edit + affects. +- Stop wiping member name and note edits after editing a membership period, + stop deleting a weekly note on tap, make the packet raw/table toggle + reversible, and prevent double restores. +- Move every repository, file, and network call off the main thread. +- Truncate document-provider files when exporting so overwritten files cannot + keep stale trailing bytes. + ## 2.4.2 - 2026-08-27 ### Added diff --git a/README.md b/README.md index 7292e36..41ac6ae 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ never stored. - Decodes each Platoon's banner frame and mark from `21905` and displays the matching original in-game emblem in profile selectors. - Tracks active and withdrawn members, non-overlapping repeat membership periods, exact Updates timestamps, editable nicknames, and private notes. -- Builds Sunday-to-Saturday Standard or Gunsmoke Frontline weekly tables around the selected server's daily reset, with cut-off points and manual correction for missing data. +- Builds Sunday-to-Saturday Standard or Gunsmoke Frontline weekly tables around the selected server's daily reset, with a frozen member column, a sticky day header, cut-off points, and per-day manual correction for missing data. - Offers One-time Capture that tracks the four useful Platoon payloads and stops automatically when the checklist is complete. - Explains every weekly cell on tap and summarizes missing or uncertain evidence in an Evidence Health panel. - Keeps up to 15 complete automatic revisions per weekly table so an earlier projection and its displayed member context can be previewed and restored after an accidental import. @@ -35,10 +35,11 @@ never stored. - Saves or shares a weekly PNG with opt-in controls for names, UIDs, and private notes. - Can send a validated original CSV to an optional user-owned Discord incoming webhook after confirmation. - Stores the latest 100 parsed packets and up to 50 saved packets, with table and raw views, copy, export, selection, and deletion. -- Supports member sorting, persistent drag ordering, snapshot comparison, single-week and all-week CSV export, and profile-aware `.gf2backup` export/restore that leaves other Platoons unchanged. +- Supports member sorting, persistent drag ordering, snapshot comparison, single-week and all-week CSV export, and profile-aware `.gf2backup` export/restore in Settings that detects whether a backup includes app settings and leaves other Platoons unchanged. - Keeps a newly detected Platoon's packets in bounded memory until the user confirms one of the verified client's compatible servers; unconfirmed data is discarded on force-stop or process death. - 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. +- 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. +- Draws edge to edge and keeps drafts, selections, and pending exports across rotation, theme or language changes, and process recreation. - Supports English and Korean, System/Light/Dark themes, and the six known Darkwinter/HaoPlay server-region reset presets converted to the phone timezone. Selecting a detected Platoon automatically follows that profile's saved region. diff --git a/README_KR.md b/README_KR.md index 4f03229..1f7d499 100644 --- a/README_KR.md +++ b/README_KR.md @@ -17,7 +17,7 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android - `21905`에서 각 서클의 배너 프레임과 도안을 해석하고, 서클 선택 화면에 대응하는 게임 원본 엠블럼을 표시합니다. - 가입 및 탈퇴 멤버, 서로 겹치지 않는 반복 가입 이력, 동향의 정확한 시각, 수정 가능한 닉네임과 개인 비고를 관리합니다. -- 선택한 서버의 일일 초기화 시각을 기준으로 일요일부터 토요일까지의 일반 주간 또는 흙먼지 주간 표를 만들며, 커트라인과 누락 데이터 수동 수정을 지원합니다. +- 선택한 서버의 일일 초기화 시각을 기준으로 일요일부터 토요일까지의 일반 주간 또는 흙먼지 주간 표를 만들며, 멤버 열 고정과 날짜 머리글 고정, 커트라인, 날짜별 누락 데이터 수동 수정을 지원합니다. - 한 번만 캡처는 네 가지 유용한 서클 페이로드의 수집 상태를 표시하고 체크리스트가 완료되면 자동으로 중지합니다. - 모든 주간 셀을 누르면 근거를 설명하고, 근거 상태 패널에서 누락되거나 불확실한 데이터를 요약합니다. - 주간 표마다 표시 당시의 멤버 문맥까지 포함한 전체 자동 기록을 최대 15개 보관하여 잘못 가져온 뒤에도 이전 표를 미리 보고 복원할 수 있습니다. @@ -29,10 +29,11 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android - 이름, UID, 비공개 메모의 포함 여부를 선택해 주간 PNG를 저장하거나 공유할 수 있습니다. - 확인 후 검증된 원본 CSV를 사용자가 소유한 선택적 Discord 수신 웹훅으로 전송할 수 있습니다. - 최근 파싱 패킷 100개와 저장 패킷 50개를 보관하며, 표 및 원본 보기, 복사, 내보내기, 선택, 삭제를 지원합니다. -- 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 다른 서클을 변경하지 않는 프로필별 백업 및 복원을 지원합니다. +- 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 다른 서클을 변경하지 않는 프로필별 백업 및 복원을 설정에서 지원하며, 복원 시 앱 설정 포함 여부를 자동으로 판별합니다. - 새 서클은 확인된 클라이언트에 맞는 서버를 사용자가 선택할 때까지 패킷을 제한된 메모리에만 보관하며, 강제 종료 또는 프로세스 종료 시 확인되지 않은 데이터를 버립니다. - 서버 정보는 데이터를 이동하지 않고 수정할 수 있으며, 서클 삭제는 두 번의 확인과 정확한 서클 이름 입력을 거쳐 해당 격리 데이터만 제거합니다. -- 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다. +- 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다. 안내는 설정에서 다시 볼 수 있습니다. +- 화면을 가장자리까지 그리며, 회전·테마·언어 변경이나 프로세스 재생성 후에도 작성 중인 내용과 선택, 대기 중인 내보내기를 유지합니다. - 영어와 한국어, 시스템/라이트/다크 테마를 지원합니다. 알려진 Darkwinter/HaoPlay 6개 서버 지역의 초기화 시각을 기기 시간대로 환산하며, 감지된 서클을 선택하면 해당 프로필에 저장된 지역을 자동으로 따릅니다. - HaoPlay(`com.haoplay.game.and.exilium`)와 Darkwinter(`com.Sunborn.SnqxExilium.Glo`) Android 클라이언트를 별도의 VPN 대상으로 등록합니다. - 다음 열 순서의 UTF-8 서클 멤버 CSV 파일을 만듭니다. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8f3456f..a8a905b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -18,3 +18,9 @@ zdtun is dynamically packaged as `libzdtun.so`; mobileGF2logger's JNI integratio - License declaration: MIT mobileGF2logger reproduces the documented framing, recognized payload identifiers, protobuf field mappings, and Platoon CSV column order. It does not bundle the Python application or generated Python protobuf runtime. + +## Material icons + +- Project: [google/material-design-icons](https://github.com/google/material-design-icons) +- License: Apache License 2.0 +- Bundled source: vector paths in `app/src/main/res/drawable/ic_*.xml` diff --git a/app/build.gradle b/app/build.gradle index bfcdab5..9a77e2d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,8 +154,8 @@ android { applicationId = 'dev.gf2log' minSdk = 26 targetSdk = 36 - versionCode = 20402 - versionName = '2.4.2' + versionCode = 20500 + versionName = '2.5.0' testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner' ndk { From 7941ad967bccc5640fa3862ba5bda1d4cf77a3e0 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:39:29 +0900 Subject: [PATCH 039/125] refactor: reuse paints across weekly PNG cells --- .../dev/gf2log/app/WeeklyReportPngRenderer.kt | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt index 8263a50..bf00807 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportPngRenderer.kt @@ -42,6 +42,7 @@ 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) @@ -73,7 +74,7 @@ object WeeklyReportPngRenderer { var top = PADDING + TITLE_HEIGHT var left = PADDING - drawCell(canvas, document.headers.first(), left, top, memberWidth, HEADER_HEIGHT, header = true) + drawCell(canvas, document.headers.first(), left, top, memberWidth, HEADER_HEIGHT, paints, header = true) left += memberWidth document.headers.drop(1).forEachIndexed { index, value -> val isLast = index == document.headers.lastIndex - 1 @@ -82,23 +83,23 @@ object WeeklyReportPngRenderer { index == days -> totalWidth else -> dayWidth } - drawCell(canvas, value, left, top, cellWidth, HEADER_HEIGHT, header = true) + drawCell(canvas, value, left, top, cellWidth, HEADER_HEIGHT, paints, header = true) left += cellWidth } top += HEADER_HEIGHT document.rows.forEach { row -> left = PADDING - drawCell(canvas, row.member, left, top, memberWidth, rowHeight) + drawCell(canvas, row.member, left, top, memberWidth, rowHeight, paints) left += memberWidth row.dailyCells.forEach { value -> - drawCell(canvas, value, left, top, dayWidth, rowHeight) + drawCell(canvas, value, left, top, dayWidth, rowHeight, paints) left += dayWidth } - drawCell(canvas, row.total, left, top, totalWidth, rowHeight) + drawCell(canvas, row.total, left, top, totalWidth, rowHeight, paints) left += totalWidth if (document.includeNotes) { - drawCell(canvas, row.privateNote.orEmpty(), left, top, notesWidth, rowHeight) + drawCell(canvas, row.privateNote.orEmpty(), left, top, notesWidth, rowHeight, paints) } top += rowHeight } @@ -112,27 +113,15 @@ object WeeklyReportPngRenderer { top: Int, width: Int, height: Int, + paints: CellPaints, header: Boolean = false, ) { - val fill = Paint().apply { - color = if (header) HEADER else SURFACE - style = Paint.Style.FILL - } - val border = Paint().apply { - color = OUTLINE - style = Paint.Style.STROKE - strokeWidth = 1f - } - canvas.drawRect(left.toFloat(), top.toFloat(), (left + width).toFloat(), (top + height).toFloat(), fill) - canvas.drawRect(left.toFloat(), top.toFloat(), (left + width).toFloat(), (top + height).toFloat(), border) - val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = INK - textSize = if (header) 18f else 16f - typeface = Typeface.create( - Typeface.DEFAULT, - if (header) Typeface.BOLD else Typeface.NORMAL, - ) - } + val textPaint = if (header) paints.headerText else paints.bodyText + paints.fill.color = if (header) HEADER else SURFACE + val right = (left + width).toFloat() + 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 @@ -145,6 +134,24 @@ object WeeklyReportPngRenderer { } } + /** 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 } + val border = Paint().apply { + color = OUTLINE + style = Paint.Style.STROKE + strokeWidth = 1f + } + val headerText = text(18f, Typeface.BOLD) + val bodyText = text(16f, Typeface.NORMAL) + + private fun text(size: Float, style: Int) = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = INK + textSize = size + typeface = Typeface.create(Typeface.DEFAULT, style) + } + } + private fun wrap(value: String, paint: Paint, maxWidth: Int): List = buildList { value.lineSequence().forEach { source -> var remaining = source.trim() From 2ad1c9c6b1cb5950eba03911555ebe6a2676dd64 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 11:46:03 +0900 Subject: [PATCH 040/125] fix: close UI review findings - Keep pending merit, score, and attempt edits when a cell is reopened. - Reuse one weekly table per week kind so recycled rows are not leaked and horizontal scroll survives re-renders. - Show the next pending Platoon after one is resolved, and let only the owning screen dismiss the admission prompt. - Bind weekly drafts to their Platoon and lock Platoon switching and the bottom bar while editing. - Keep cutline toggles and the weekly note draft across recreation. - Report segmented choices immediately, expose required packet types to TalkBack, read inputs on the main thread, refresh the selection title after reloads, refresh the visible screen after profile maintenance, and fall back to plain write mode for providers that reject truncation. --- .../main/java/dev/gf2log/app/BaseActivity.kt | 12 ++++++- .../dev/gf2log/app/MemberDetailActivity.kt | 6 ++-- .../app/PendingPlatoonAdmissionPrompt.kt | 22 ++++++++---- .../java/dev/gf2log/app/PlatoonActivity.kt | 4 ++- .../dev/gf2log/app/PlatoonProfileSelector.kt | 6 ++-- .../main/java/dev/gf2log/app/WeeklyDialogs.kt | 6 ++-- .../dev/gf2log/app/WeeklyReportActivity.kt | 36 +++++++++++++++---- .../dev/gf2log/app/WeeklySettingsActivity.kt | 10 +++++- .../main/java/dev/gf2log/app/WeeklyTable.kt | 9 ++--- .../main/java/dev/gf2log/app/ui/Controls.kt | 3 +- .../java/dev/gf2log/app/ui/DocumentAccess.kt | 12 +++++-- app/src/main/java/dev/gf2log/app/ui/Screen.kt | 9 +++++ app/src/main/java/dev/gf2log/app/ui/Views.kt | 8 ++++- 13 files changed, 111 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/BaseActivity.kt b/app/src/main/java/dev/gf2log/app/BaseActivity.kt index ff1023a..40fd6f0 100644 --- a/app/src/main/java/dev/gf2log/app/BaseActivity.kt +++ b/app/src/main/java/dev/gf2log/app/BaseActivity.kt @@ -57,7 +57,7 @@ abstract class BaseActivity : ComponentActivity() { override fun onStop() { PendingPlatoonAdmissionStore.removeListener(admissionListener) - PendingPlatoonAdmissionPrompt.dismiss() + PendingPlatoonAdmissionPrompt.dismiss(this) super.onStop() } @@ -70,9 +70,19 @@ abstract class BaseActivity : ComponentActivity() { recreate() return } + foreground = java.lang.ref.WeakReference(this) if (!isFinishing) onScreenResumed() } /** Called from onResume only when this instance remains current. */ protected open fun onScreenResumed() = Unit + + companion object { + @Volatile + private var foreground = java.lang.ref.WeakReference(null) + + /** The most recently resumed screen, used to refresh after work that outlived its caller. */ + internal fun foregroundScreen(): BaseActivity? = + foreground.get()?.takeUnless { it.isFinishing || it.isDestroyed } + } } diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index ae08a2a..14c455c 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -303,8 +303,9 @@ class MemberDetailActivity : BaseActivity() { return@setOnClickListener } button.isEnabled = false + val noteText = noteInput.text.toString() work.run( - task = { repository.updateMembershipPeriod(period.id, joinedBoundary, leftBoundary, noteInput.text.toString()) }, + task = { repository.updateMembershipPeriod(period.id, joinedBoundary, leftBoundary, noteText) }, ) { result -> button.isEnabled = true if (result.getOrDefault(false)) { @@ -366,8 +367,9 @@ class MemberDetailActivity : BaseActivity() { return@setOnClickListener } button.isEnabled = false + val noteText = noteInput.text.toString() work.run( - task = { repository.addMembershipPeriod(uid, joinedBoundary, withdrewBoundary, noteInput.text.toString()) }, + task = { repository.addMembershipPeriod(uid, joinedBoundary, withdrewBoundary, noteText) }, ) { result -> button.isEnabled = true if (result.getOrDefault(false)) { diff --git a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt index 3ba72f9..1b2433c 100644 --- a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt @@ -29,6 +29,7 @@ import java.lang.ref.WeakReference */ internal object PendingPlatoonAdmissionPrompt { private var visible = WeakReference(null) + private var owner = WeakReference(null) private var visibleToken: String? = null private val selections = HashMap() @@ -36,16 +37,25 @@ internal object PendingPlatoonAdmissionPrompt { if (activity.isFinishing || activity.isDestroyed) return val showing = visible.get()?.takeIf { it.isShowing } if (showing != null) { - if (candidates.none { it.token == visibleToken }) showing.dismiss() - return + if (candidates.any { it.token == visibleToken }) return + // The visible candidate was resolved; move on to the next queued one. + showing.dismiss() + clearVisible() } selections.keys.retainAll(candidates.mapTo(mutableSetOf()) { it.token }) candidates.firstOrNull()?.let { show(activity, it) } } - fun dismiss() { + /** Dismisses the prompt only when [activity] owns it, so a newly started screen keeps its own. */ + fun dismiss(activity: Activity) { + if (owner.get() !== activity) return visible.get()?.dismiss() + clearVisible() + } + + private fun clearVisible() { visible.clear() + owner.clear() visibleToken = null } @@ -107,12 +117,10 @@ internal object PendingPlatoonAdmissionPrompt { shown.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { confirmDiscard(activity, candidate) } } dialog.setOnDismissListener { - if (visible.get() === dialog) { - visible.clear() - visibleToken = null - } + if (visible.get() === dialog) clearVisible() } visible = WeakReference(dialog) + owner = WeakReference(activity) visibleToken = candidate.token } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index 4d4dd91..fe3d933 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -168,6 +168,7 @@ class PlatoonActivity : BaseActivity() { onSuccess = { loaded -> model = loaded selected.retainAll(loaded.statuses.mapTo(mutableSetOf(), MemberStatus::uid)) + if (selecting) setSelecting(true) renderSummary(loaded) renderMembers() }, @@ -410,6 +411,7 @@ class PlatoonActivity : BaseActivity() { return@setOnClickListener } button.isEnabled = false + val noteText = note.input.text.toString() work.run( task = { repository.addWithdrawnMember( @@ -417,7 +419,7 @@ class PlatoonActivity : BaseActivity() { name, requireNotNull(joinedBoundary), requireNotNull(withdrewBoundary), - note.input.text.toString(), + noteText, ) }, ) { result -> diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index d5aae83..33e1f5b 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -291,8 +291,10 @@ internal object PlatoonProfileSelector { val appContext = activity.applicationContext maintenanceExecutor.execute { val result = runCatching(operation) - val current = screen.get() - if (current == null || current.isFinishing || current.isDestroyed) { + // 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) return@execute } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt index 172523f..53eb220 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -150,9 +150,9 @@ internal object WeeklyDialogs { WeeklyOverrideEditing.CellEdit( uid = uid, gameDay = cell.gameDay, - merit = changed(merit to meritInitial), - score = changed(score), - attempts = changed(attempts), + merit = changed(merit to meritInitial) ?: current?.merit, + score = changed(score) ?: current?.score, + attempts = changed(attempts) ?: current?.attempts, attended = login.takeIf { it != loginInitial } ?: current?.attended, dailyPatrol = patrol.takeIf { it != patrolInitial } ?: current?.dailyPatrol, ), diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 3c87532..52bd62f 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -96,7 +96,10 @@ class WeeklyReportActivity : BaseActivity() { private var model: RenderModel? = null private var table: WeeklyTable? = null - private var tableGeneration = 0 + + /** One table per week kind, reused across renders so recycled rows and scroll position survive. */ + private val tables = HashMap() + private val horizontalSync = HorizontalSync() private var editingPeriod: LocalDate? = null private val edits = linkedMapOf, WeeklyOverrideEditing.CellEdit>() private var noteDay: LocalDate? = null @@ -160,6 +163,7 @@ class WeeklyReportActivity : BaseActivity() { onBackPressedDispatcher.addCallback(this, editingBack) screen = Screen(this, PrimaryNavigation.Destination.WEEKLY) buildNoteComposer() + savedInstanceState?.getString(STATE_NOTE_DRAFT)?.let(noteInput::setText) bottomSections = column() bottomAdapter.view = column { setPadding(dp(16), 0, dp(16), dp(24)) @@ -201,10 +205,13 @@ class WeeklyReportActivity : BaseActivity() { WeeklyPngExport.nameForState(cacheDir, pendingPng)?.let { outState.putString(STATE_PENDING_PNG_NAME, it) } pendingExport?.let { outState.putString(STATE_PENDING_EXPORT, it) } editingPeriod?.let { period -> + outState.putString(STATE_EDITING_SCOPE, scope.storageId) outState.putLong(STATE_EDITING_PERIOD, period.toEpochDay()) outState.putParcelableArrayList(STATE_EDITS, ArrayList(edits.values.map(::encodeEdit))) } noteDay?.let { outState.putLong(STATE_NOTE_DAY, it.toEpochDay()) } + // The composer lives inside the RecyclerView, which does not save its children's state. + if (::noteInput.isInitialized) outState.putString(STATE_NOTE_DRAFT, noteInput.text.toString()) super.onSaveInstanceState(outState) } @@ -217,7 +224,8 @@ class WeeklyReportActivity : BaseActivity() { ?.let(LocalDate::ofEpochDay) ?: PlatoonPeriods.gameDay(Instant.now(), GameTimeZonePreferences.get(this, scope.storageId)), ) - if (state?.containsKey(STATE_EDITING_PERIOD) == true) { + // Edits belong to one Platoon; a profile switch recreates this screen in another scope. + if (state?.containsKey(STATE_EDITING_PERIOD) == true && state.getString(STATE_EDITING_SCOPE) == scope.storageId) { editingPeriod = LocalDate.ofEpochDay(state.getLong(STATE_EDITING_PERIOD)) @Suppress("DEPRECATION") state.getParcelableArrayList(STATE_EDITS) @@ -290,9 +298,14 @@ class WeeklyReportActivity : BaseActivity() { edits.clear() } model = next - val weeklyTable = WeeklyTable(this, report.isGunsmokeWeek, next.zone, next.cutlines) + val weeklyTable = tables.getOrPut(report.isGunsmokeWeek) { + WeeklyTable(this, report.isGunsmokeWeek, next.zone, next.cutlines, horizontalSync) + }.apply { + zone = next.zone + cutlines = next.cutlines + now = Instant.now() + } table = weeklyTable - tableGeneration += 1 topAdapter.view = buildTop(next) stickyHeader.removeAllViews() if (report.members.isEmpty()) { @@ -333,7 +346,11 @@ class WeeklyReportActivity : BaseActivity() { val editing = editingPeriod == report.periodStart return column { setPadding(dp(16), 0, dp(16), dp(12)) - addView(PlatoonProfileSelector.card(this@WeeklyReportActivity, compact = true), lp(top = 4)) + addView(PlatoonProfileSelector.card(this@WeeklyReportActivity, compact = true).apply { + // Switching Platoons mid-edit would discard the draft without confirmation. + isEnabled = !editing + alpha = if (editing) 0.6f else 1f + }, lp(top = 4)) addView(weekNavigator(next, editing), lp(top = 12)) if (next.restoredHistory) { addView(panel(emphasized = true, padding = 12) { @@ -570,6 +587,7 @@ class WeeklyReportActivity : BaseActivity() { val current = model val editing = current != null && editingPeriod == current.report.periodStart editingBack.isEnabled = editing + screen.setNavigationEnabled(!editing) val bar = screen.topBar if (editing) { bar.setNavigationIcon(R.drawable.ic_close, getString(R.string.cancel_editing)) @@ -844,10 +862,10 @@ class WeeklyReportActivity : BaseActivity() { override fun getItemCount() = members.size - override fun getItemViewType(position: Int) = tableGeneration + override fun getItemViewType(position: Int) = if (table?.isGunsmokeWeek == true) GUNSMOKE_ROW else STANDARD_ROW override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RowHolder = - RowHolder(requireNotNull(table).MemberRow()) + RowHolder(requireNotNull(tables[viewType == GUNSMOKE_ROW]).MemberRow()) override fun onBindViewHolder(holder: RowHolder, position: Int) { val member = members[position] @@ -897,6 +915,8 @@ class WeeklyReportActivity : BaseActivity() { private companion object { const val RENDER = "render" + const val STANDARD_ROW = 0 + const val GUNSMOKE_ROW = 1 const val SAVE = "save" const val EXPORT_ALL = "all" const val MAX_NOTE_LENGTH = 1_000 @@ -904,7 +924,9 @@ class WeeklyReportActivity : BaseActivity() { const val STATE_PENDING_PNG_NAME = "weekly.pending_png_name" const val STATE_PENDING_EXPORT = "weekly.pending_export" const val STATE_EDITING_PERIOD = "weekly.editing_period" + const val STATE_EDITING_SCOPE = "weekly.editing_scope" const val STATE_EDITS = "weekly.edits" const val STATE_NOTE_DAY = "weekly.note_day" + const val STATE_NOTE_DRAFT = "weekly.note_draft" } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt index 21f746a..9fbedd9 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt @@ -31,6 +31,7 @@ class WeeklySettingsActivity : BaseActivity() { private lateinit var preferences: WeeklyCutlinePreferences private lateinit var initial: WeeklyCutlines private val inputs = mutableListOf() + private var restoredEnabled: BooleanArray? = null private val unsavedBack = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() = leave() } @@ -44,6 +45,7 @@ class WeeklySettingsActivity : BaseActivity() { } preferences = WeeklyCutlinePreferences(this, scope.storageId) initial = preferences.read() + restoredEnabled = savedInstanceState?.getBooleanArray(STATE_ENABLED) onBackPressedDispatcher.addCallback(this, unsavedBack) val screen = Screen(this) screen.topBar @@ -81,7 +83,7 @@ class WeeklySettingsActivity : BaseActivity() { } val supporting = maximum?.let { getString(R.string.cutline_range, it) } val field = LabeledField(context, getString(R.string.cutline_value), input, supporting) - val threshold = Threshold(input, field, maximum, value != null) + val threshold = Threshold(input, field, maximum, restoredEnabled?.getOrNull(inputs.size) ?: (value != null)) field.visibility = if (threshold.enabled) View.VISIBLE else View.GONE addView(switchRow(getString(label), supporting, threshold.enabled) { checked -> threshold.enabled = checked @@ -92,6 +94,11 @@ class WeeklySettingsActivity : BaseActivity() { inputs += threshold } + override fun onSaveInstanceState(outState: Bundle) { + outState.putBooleanArray(STATE_ENABLED, inputs.map(Threshold::enabled).toBooleanArray()) + super.onSaveInstanceState(outState) + } + private fun readValues(): List? { var valid = true val values = inputs.map { threshold -> @@ -155,5 +162,6 @@ class WeeklySettingsActivity : BaseActivity() { const val DAILY_ATTEMPTS = 3 const val WEEKLY_ATTEMPTS = 21 const val WEEK_DAYS = 7 + const val STATE_ENABLED = "cutlines.enabled" } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt index df6bc7c..1b0813d 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt @@ -98,11 +98,12 @@ internal data class CellDisplay( internal class WeeklyTable( private val context: Context, val isGunsmokeWeek: Boolean, - private val zone: ZoneId, - private val cutlines: WeeklyCutlines, - private val now: Instant = Instant.now(), + var zone: ZoneId, + var cutlines: WeeklyCutlines, + val sync: HorizontalSync = HorizontalSync(), ) { - val sync = HorizontalSync() + /** Reference instant for open-day marks; refreshed with each render. */ + var now: Instant = Instant.now() val rowHeight = context.dp(if (isGunsmokeWeek) GUNSMOKE_ROW_HEIGHT else STANDARD_ROW_HEIGHT) fun headerRow(report: WeeklyReportBuilder.Report): View = gridRow( diff --git a/app/src/main/java/dev/gf2log/app/ui/Controls.kt b/app/src/main/java/dev/gf2log/app/ui/Controls.kt index ba5f8e0..d0f7525 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Controls.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Controls.kt @@ -228,7 +228,8 @@ internal object Controls { setOnClickListener { if (value == currentValue) return@setOnClickListener currentValue = value - moveSelection(index, animated = true) { onSelected(value) } + moveSelection(index, animated = true) + onSelected(value) } }, RadioGroup.LayoutParams(0, segmentHeight, 1f)) } 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 6b74aef..9198472 100644 --- a/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt +++ b/app/src/main/java/dev/gf2log/app/ui/DocumentAccess.kt @@ -26,8 +26,16 @@ internal object DocumentAccess { if (Paths.get(path).normalize().startsWith("/data")) { throw SecurityException("Export URI targets private app storage") } - return resolver.openOutputStream(destination, "wt") - ?: error("Document provider did not open an output stream") + // Truncate so an overwritten document cannot keep stale trailing bytes; providers that + // reject "wt" create a fresh document anyway, so plain "w" is the safe fallback. + val output = try { + resolver.openOutputStream(destination, "wt") + } catch (_: IllegalArgumentException) { + resolver.openOutputStream(destination, "w") + } catch (_: java.io.FileNotFoundException) { + resolver.openOutputStream(destination, "w") + } + return output ?: error("Document provider did not open an output stream") } fun openInput(resolver: ContentResolver, source: Uri): InputStream { diff --git a/app/src/main/java/dev/gf2log/app/ui/Screen.kt b/app/src/main/java/dev/gf2log/app/ui/Screen.kt index 57814ef..5d331c2 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Screen.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Screen.kt @@ -58,6 +58,15 @@ internal class Screen( activity.setContentView(root) } + /** Blocks leaving through the bottom bar, e.g. while an unsaved edit is open. */ + fun setNavigationEnabled(enabled: Boolean) { + val bar = bottomBar as? android.view.ViewGroup ?: return + for (index in 0 until bar.childCount) { + bar.getChildAt(index).isEnabled = enabled + } + bar.alpha = if (enabled) 1f else 0.5f + } + /** Replaces the body; scrolling bodies draw beneath the navigation bar and pad their end. */ fun setBody(view: View) { content.removeAllViews() 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 b4a93f0..baddd1c 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Views.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -266,7 +266,13 @@ internal fun Context.switchRow( } }, lp(width = 0, weight = 1f)) // A permanently required option shows its badge instead of an inert switch. - if (enabled || badge == null) addView(toggle, lp(width = WRAP, start = 12)) + if (enabled || badge == null) { + addView(toggle, lp(width = WRAP, start = 12)) + } else { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES + isFocusable = true + contentDescription = toggle.contentDescription + } } } From 2a5a3de38696ec9e608a4dd2369bd800182c81dc Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:47:47 +0900 Subject: [PATCH 041/125] fix: name capture clients and current controls in Home and onboarding --- app/src/main/java/dev/gf2log/app/MainActivity.kt | 15 ++++++++++++++- app/src/main/res/values-ko/strings.xml | 16 +++++++++------- app/src/main/res/values/strings.xml | 16 +++++++++------- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 5d4ef18..a77177f 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -246,7 +246,7 @@ class MainActivity : BaseActivity() { private fun clientRows(): View = column { SupportedGamePackages.all.forEachIndexed { index, packageName -> val installed = isInstalled(packageName) - val client = if (packageName == SupportedGamePackages.HAOPLAY) "HaoPlay" else "Darkwinter" + val client = clientName(packageName) val status = getString(if (installed) R.string.client_installed else R.string.client_not_installed) addView(row { minimumHeight = dp(40) @@ -291,6 +291,7 @@ class MainActivity : BaseActivity() { ) val notice = snapshot.notice stateDetail.text = 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) else -> getString(R.string.capture_preparing_detail) @@ -311,6 +312,18 @@ class MainActivity : BaseActivity() { } } + private fun clientNames(packages: List): String { + val names = packages.map(::clientName) + return when (names.size) { + 0 -> getString(R.string.none) + 1 -> names.single() + else -> getString(R.string.clients_and, names[0], names.drop(1).joinToString(", ")) + } + } + + private fun clientName(packageName: String) = + if (packageName == SupportedGamePackages.HAOPLAY) "HaoPlay" else if (packageName == SupportedGamePackages.DARKWINTER) "Darkwinter" else packageName + private fun renderChecklist(guided: CaptureStatus.GuidedProgress?) { checklist.removeAllViews() checklist.visibility = if (guided == null) View.GONE else View.VISIBLE diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 48b7700..417e4cd 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -31,29 +31,29 @@ 환영합니다 메인 화면 소녀전선2: 망명 클라이언트의 통신 패킷을 캡처하고, 패킷 수집 현황을 한눈에 확인합니다. - 캡처 준비는 사용자가 중지할 때까지 계속 실행됩니다. + 계속 캡처는 사용자가 중지할 때까지 실행됩니다. 한 번만 캡처는 서클 식별 정보, 멤버, 활동, 동향을 확인한 뒤 자동으로 중지합니다. - 최근 패킷 및 저장한 패킷 기록은 로컬에 보관됩니다. + 최근·보관 패킷은 이 휴대전화의 최근/보관 탭에 보관됩니다. VPN 동의 화면이 표시됩니다. GF2logger는 소녀전선2: 망명 앱에 대해서만 통신 패킷을 관찰하며, TLS를 해독하거나 원본 네트워크 패킷을 저장하지 않습니다. 설정 화면에 표시되는 정보를 조정하고, 기기에 저장하거나 외부로 보내는 정보를 관리합니다. 언어와 테마를 선택합니다. - 전체 백업을 만들거나 복원합니다. + 서클 데이터를 앱 설정과 함께 또는 따로 백업하고 복원합니다. Discord 웹훅을 설정하고 필요한 패킷 종류를 선택합니다. 서클 관리 캡처하거나 불러온 멤버 데이터가 선택한 서클의 멤버 정보 및 주간 표에 반영됩니다. 현재 가입 중이거나 과거에 탈퇴한 멤버의 정보와 가입·탈퇴 이력을 검색하고 관리합니다. CSV를 적용하기 전에 변경 내용을 미리 보고, 자동 체크포인트로 최근 가져오기를 되돌릴 수 있습니다. - 분리된 서클을 전환하고, 스냅샷을 비교하거나 선택한 멤버만 내보낼 수 있습니다. + 분리된 서클을 전환하고, 스냅샷을 비교하거나 멤버를 길게 눌러 선택해 내보낼 수 있습니다. 주간 테이블 일반 주간과 흙먼지 주간이 구분된 주간 공적표를 확인합니다. 각 셀을 눌러 해당 값의 근거와 사용된 패킷 정보를 확인할 수 있습니다. - 수동으로 공적치나 흙먼지 점수 등을 수정할 수 있으며, 공유 버튼을 통해 주간 테이블을 저장하거나 공유할 수 있습니다. + 연필 버튼으로 공적이나 흙먼지 값을 수정하고, 공유 버튼으로 주간 표를 저장하거나 공유합니다. 커트라인 설정은 선택한 기준보다 낮거나 낮을 가능성이 있는 셀을 강조합니다. 파싱된 패킷 화면 기록을 열어 표 또는 디코딩된 CSV 전체 내용을 확인하거나 내보냅니다. - 원본 CSV 보기는 디코딩된 전체 내용을 표시하고, 복사는 클립보드에 저장합니다. - 원본 CSV 전송은 설정된 Discord 웹훅으로 전송됩니다. + 표와 디코딩된 원본 텍스트를 오가며 볼 수 있고, 복사하면 클립보드에 저장됩니다. + 메뉴에서 원본 CSV를 Discord 웹훅으로 보낼 수 있습니다. 패킷에 담겨 있는 여러 정보를 통해 멤버 및 주간 테이블을 완성해 나갈 수 있습니다. 백업 백업 복원 @@ -580,4 +580,6 @@ 서클 백업을 복원했습니다 GF2logger — %1$s 멤버 %1$d + %1$s 통신을 보는 중입니다. 게임에서 서클 화면을 여세요. + %1$s, %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12871b6..90741e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,29 +31,29 @@ Welcome to Main page Capture communications from the GIRLS\' FRONTLINE 2: EXILIUM client and see packet collection progress at a glance. - Prepare capture keeps running until you stop it. + Continuous capture keeps running until you stop it. One-time Capture checks Platoon identity, Members, Activity, and Updates, then stops automatically. - Recent and saved packet history is stored locally. + Recent and saved packets stay on this phone, under the Recent and Saved tabs. Android will show its VPN consent screen. GF2logger observes communications only for GIRLS\' FRONTLINE 2: EXILIUM, does not decrypt TLS, and never stores raw network packets. Settings Adjust what appears on screen and manage information stored on the device or sent outside it. Choose the language and theme. - Create or restore a complete backup. + Back up or restore a Platoon, with or without app settings. Configure a Discord webhook and select the packet types you need. Platoon management Captured or imported member data is reflected in the selected Platoon’s member records and weekly table. Search and manage current or withdrawn members and their join and withdrawal history. Preview CSV imports before commit and use the automatic checkpoint to undo the latest import. - Switch between isolated Platoons, compare snapshots, or export only selected members. + Switch between isolated Platoons, compare snapshots, or long-press members to export them. Weekly table Review weekly merit tables separated into Standard and Gunsmoke weeks. Tap any cell to see the basis for its value and the packet evidence used. - Manually correct merit or Gunsmoke values, and save or share the weekly table with the Share button. + Edit merit or Gunsmoke values with the pencil button, and save or share the table with the share button. Cut-off settings highlight cells that are or may remain below your selected targets. Parsed packet pages Open a history entry to inspect or export its table or complete decoded CSV text. - Show raw CSV reveals the original decoded content; Copy places it on the clipboard. - Send original CSV sends it through the configured Discord webhook. + Switch between the table and the raw decoded text; copy places it on the clipboard. + Send the original CSV to your Discord webhook from the menu. Use the information in each packet to complete member records and the weekly table over time. Backup Restore backup @@ -588,4 +588,6 @@ Platoon backup restored GF2logger — %1$s Member %1$d + Watching %1$s. Open the Platoon pages in the game. + %1$s and %2$s From 325c7506c49427c0baf292fa4610a8f4e5d22340 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:52:08 +0900 Subject: [PATCH 042/125] fix: ignore repeated native flow close callbacks --- app/src/main/cpp/gf2capture.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/cpp/gf2capture.c b/app/src/main/cpp/gf2capture.c index 39dcea2..73c70b7 100644 --- a/app/src/main/cpp/gf2capture.c +++ b/app/src/main/cpp/gf2capture.c @@ -306,6 +306,11 @@ static void connection_close_callback(zdtun_t *tunnel, const zdtun_conn_t *conne return; } + // zdtun invokes this callback twice when the RST it sends while closing cannot be written to + // the TUN (send_to_client re-enters zdtun_conn_close). Detach first so the second invocation is + // a no-op instead of a duplicate onFlowClosed and a double free. + zdtun_conn_set_userdata((zdtun_conn_t *) connection, NULL); + if (flow->tracked && context->env != NULL) { (*context->env)->CallVoidMethod( context->env, From 435a0c4fb13f9762e8e10d86c5fe44615fd1e357 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:52:19 +0900 Subject: [PATCH 043/125] fix: keep native flow ids unique across capture sessions --- app/src/main/cpp/gf2capture.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/cpp/gf2capture.c b/app/src/main/cpp/gf2capture.c index 73c70b7..57dd442 100644 --- a/app/src/main/cpp/gf2capture.c +++ b/app/src/main/cpp/gf2capture.c @@ -51,7 +51,6 @@ typedef struct capture_context { pthread_t thread; atomic_bool running; int tun_fd; - int64_t next_flow_id; uint64_t sent_bytes; uint64_t received_bytes; uint64_t inspected_bytes; @@ -59,6 +58,9 @@ typedef struct capture_context { static pthread_mutex_t context_mutex = PTHREAD_MUTEX_INITIALIZER; static capture_context_t *active_context = NULL; +// Process-wide so a flow ID is never reused by a later capture: unconfirmed Platoon candidates +// outlive the capture that observed them and refer to their flows by ID. +static _Atomic int64_t last_flow_id = 0; static bool clear_java_exception(capture_context_t *context, const char *operation) { if (!(*context->env)->ExceptionCheck(context->env)) { @@ -263,7 +265,7 @@ static int connection_open_callback(zdtun_t *tunnel, zdtun_conn_t *connection) { } const zdtun_5tuple_t *tuple = zdtun_conn_get_5tuple(connection); - flow->id = ++context->next_flow_id; + flow->id = atomic_fetch_add(&last_flow_id, 1) + 1; // Only TCP streams can carry game frames, so other flows never reach Java. flow->tracked = tuple->ipproto == IPPROTO_TCP; flow->inspect_payload = flow->tracked; From afe7bb9814065ee94d501a834cb970c6b67f1184 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:52:28 +0900 Subject: [PATCH 044/125] feat: surface the latest roster change on Home and mark today in the weekly grid Home shows when the roster was last captured and how many members joined or withdrew, opening the snapshot comparison directly. The weekly header highlights the open game day, the selected bottom tab sits on an accent pill, and the capture panel tints while capturing. --- .../main/java/dev/gf2log/app/MainActivity.kt | 61 ++++++++++++++++++- .../java/dev/gf2log/app/PrimaryNavigation.kt | 13 +++- .../main/java/dev/gf2log/app/WeeklyTable.kt | 14 +++-- app/src/main/res/values-ko/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ 5 files changed, 89 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index a77177f..b79105f 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -27,6 +27,9 @@ import dev.gf2log.app.capture.format import dev.gf2log.app.history.CaptureHistoryStore import dev.gf2log.app.history.SavedHistoryStore import dev.gf2log.app.management.PlatoonCsvImportStore +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.SnapshotComparison +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.AccessibleRow import dev.gf2log.app.ui.BackgroundWork @@ -70,6 +73,7 @@ class MainActivity : BaseActivity() { private var historyStore: CaptureHistoryStore? = null private var savedStore: SavedHistoryStore? = null + private lateinit var capturePanel: LinearLayout private lateinit var stateDot: View private lateinit var stateTitle: TextView private lateinit var stateDetail: TextView @@ -78,6 +82,7 @@ class MainActivity : BaseActivity() { private lateinit var progressBanner: LinearLayout private lateinit var progressText: TextView private lateinit var historySection: LinearLayout + private lateinit var rosterSummary: LinearLayout private var captureOnceRequested = false private var showingSaved = false @@ -170,6 +175,7 @@ class MainActivity : BaseActivity() { return } loadHistory() + loadRosterSummary() csvImport.onScreenReady() } @@ -198,6 +204,8 @@ class MainActivity : BaseActivity() { addView(capturePanel(), lp(top = 4)) addView(sectionHeader(getString(R.string.active_platoon_section)), lp()) addView(PlatoonProfileSelector.card(this@MainActivity), lp()) + rosterSummary = column { visibility = View.GONE } + addView(rosterSummary, lp(top = 8)) addView(sectionHeader(getString(R.string.data_tools)), lp()) addView(group(listOf( navigationRow(getString(R.string.import_platoon_csv), getString(R.string.import_platoon_csv_detail), R.drawable.ic_upload) { @@ -230,7 +238,7 @@ class MainActivity : BaseActivity() { stateDetail = text(null, TextRole.SECONDARY) { maxLines = 4 } checklist = column { visibility = View.GONE } captureActions = row(Gravity.TOP) - return panel(outlined = true) { + capturePanel = panel(outlined = true) { addView(row { addView(stateDot, lp(10, 10, end = 8)) addView(text(getString(R.string.capture_status_label), TextRole.LABEL), lp(width = 0, weight = 1f)) @@ -241,6 +249,7 @@ class MainActivity : BaseActivity() { addView(clientRows(), lp(top = 12)) addView(captureActions, lp(top = 12)) } + return capturePanel } private fun clientRows(): View = column { @@ -280,6 +289,11 @@ class MainActivity : BaseActivity() { } stateTitle.text = getString(title) stateTitle.setTextColor(getColor(color)) + // A running capture tints the whole panel so its state reads from across the room. + (capturePanel.background as GradientDrawable).apply { + setColor(getColor(if (phase == CapturePhase.RUNNING) R.color.success_surface else R.color.surface)) + setStroke(dp(1), getColor(if (phase == CapturePhase.RUNNING) R.color.success_mark else R.color.outline)) + } (stateDot.background as GradientDrawable).setColor( getColor( when (phase) { @@ -441,6 +455,50 @@ class MainActivity : BaseActivity() { // endregion + /** The latest roster change, so a leader sees what the last session captured without leaving Home. */ + private fun loadRosterSummary() { + val scope = profileBinding.scope ?: return + work.run( + key = SUMMARY, + task = { + val snapshots = PlatoonRepository(this, scope).listSnapshots(2) + val latest = snapshots.firstOrNull() + latest?.let { + Triple( + it, + snapshots.getOrNull(1)?.let { older -> SnapshotComparison.compare(older, it) }, + GameTimeZonePreferences.get(this, scope.storageId), + ) + } + }, + ) { result -> + val summary = result.getOrNull() + if (summary == null) { + rosterSummary.visibility = View.GONE + return@run + } + val (latest, comparison, zone) = summary + rosterSummary.removeAllViews() + rosterSummary.visibility = View.VISIBLE + val detail = buildList { + add(resources.getQuantityString(R.plurals.member_count, latest.members.size, latest.members.size)) + comparison?.let { + add(getString(R.string.roster_joined_count, it.joined.size)) + add(getString(R.string.roster_withdrew_count, it.left.size)) + } + }.joinToString(" · ") + rosterSummary.addView(group(listOf( + navigationRow( + getString(R.string.latest_roster_title, Labels.format(Labels.DATE_TIME, latest.capturedAt, zone)), + detail, + R.drawable.ic_compare, + enabled = comparison != null, + showChevron = comparison != null, + ) { startActivity(Intent(this, SnapshotComparisonActivity::class.java)) }, + )), lp()) + } + } + // region Packet history private fun loadHistory() { @@ -661,6 +719,7 @@ class MainActivity : BaseActivity() { private companion object { const val HISTORY = "history" + const val SUMMARY = "summary" const val RECENT = "recent" const val SAVED = "saved" const val COLLAPSED_COUNT = 20 diff --git a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt index b0541aa..1b03485 100644 --- a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt +++ b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt @@ -70,7 +70,7 @@ internal object PrimaryNavigation { activityClass: Class, ) { val active = destination == selected - val color = activity.getColor(if (active) R.color.accent else R.color.text_secondary) + val color = activity.getColor(if (active) R.color.accent_text else R.color.text_secondary) addView(LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER @@ -78,13 +78,22 @@ internal object PrimaryNavigation { isFocusable = true isSelected = active contentDescription = activity.getString(label) + androidx.core.view.ViewCompat.setStateDescription(this, if (active) activity.getString(R.string.selected) else null) background = activity.selectableBackground() setPadding(0, activity.dp(6), 0, activity.dp(4)) + // The selected destination sits on a soft accent pill, the rest stay neutral. addView(ImageView(activity).apply { setImageResource(icon) imageTintList = ColorStateList.valueOf(color) importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(activity.dp(24), activity.dp(24))) + scaleType = ImageView.ScaleType.CENTER + if (active) { + background = GradientDrawable().apply { + setColor(activity.getColor(R.color.accent_surface)) + cornerRadius = activity.dp(16).toFloat() + } + } + }, LinearLayout.LayoutParams(activity.dp(56), activity.dp(32))) addView(TextView(activity).apply { text = activity.getString(label) textSize = 11f diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt index 1b0813d..cc32729 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt @@ -114,6 +114,7 @@ internal class WeeklyTable( "${day.monthValue}/${day.dayOfMonth}", DAY_WIDTH, Gravity.CENTER, + today = day == PlatoonPeriods.gameDay(now, zone), ) } + headerCell(context.getString(R.string.total), null, TOTAL_WIDTH, Gravity.CENTER), height = context.dp(HEADER_HEIGHT), @@ -343,17 +344,22 @@ internal class WeeklyTable( null -> NONE_MARK } - private fun headerCell(title: String, subtitle: String?, widthDp: Int, gravity: Int): View = context.column { + /** Today's column is marked with the accent so the open game day is found at a glance. */ + private fun headerCell(title: String, subtitle: String?, widthDp: Int, gravity: Int, today: Boolean = false): View = context.column { this.gravity = gravity setPadding(context.dp(10), 0, context.dp(6), 0) - setBackgroundColor(context.getColor(R.color.table_header)) + setBackgroundColor(context.getColor(if (today) R.color.accent_surface else R.color.table_header)) addView(context.text(title, TextRole.LABEL) { - setTextColor(context.getColor(R.color.text_primary)) + setTextColor(context.getColor(if (today) R.color.accent_text else R.color.text_primary)) this.gravity = gravity }, context.lp()) subtitle?.let { - addView(context.text(it, TextRole.CAPTION) { this.gravity = gravity }, context.lp()) + addView(context.text(it, TextRole.CAPTION) { + this.gravity = gravity + if (today) setTextColor(context.getColor(R.color.accent_text)) + }, context.lp()) } + if (today) contentDescription = context.getString(R.string.today_column, title, subtitle.orEmpty()) layoutParams = LinearLayout.LayoutParams(context.dp(widthDp), LinearLayout.LayoutParams.MATCH_PARENT) } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 417e4cd..365454e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -582,4 +582,8 @@ 멤버 %1$d %1$s 통신을 보는 중입니다. 게임에서 서클 화면을 여세요. %1$s, %2$s + 오늘, %1$s %2$s + 최근 명단 · %1$s + 가입 %1$d + 탈퇴 %1$d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90741e9..9b5d7e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -590,4 +590,8 @@ Member %1$d Watching %1$s. Open the Platoon pages in the game. %1$s and %2$s + Today, %1$s %2$s + Latest roster · %1$s + joined %1$d + withdrew %1$d From 807478b786b87c5875253efc0c3dd696a98c97ad Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:52:33 +0900 Subject: [PATCH 045/125] fix: clear pending JNI exception when flow addresses cannot be allocated --- app/src/main/cpp/gf2capture.c | 45 +++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/app/src/main/cpp/gf2capture.c b/app/src/main/cpp/gf2capture.c index 57dd442..578b6ad 100644 --- a/app/src/main/cpp/gf2capture.c +++ b/app/src/main/cpp/gf2capture.c @@ -277,27 +277,32 @@ static int connection_open_callback(zdtun_t *tunnel, zdtun_conn_t *connection) { const int family = tuple->ipver == 4 ? AF_INET : AF_INET6; char local_address[INET6_ADDRSTRLEN] = {0}; char remote_address[INET6_ADDRSTRLEN] = {0}; - if (inet_ntop(family, &tuple->src_ip, local_address, sizeof(local_address)) != NULL && - inet_ntop(family, &tuple->dst_ip, remote_address, sizeof(remote_address)) != NULL) { - jstring local = (*context->env)->NewStringUTF(context->env, local_address); - jstring remote = (*context->env)->NewStringUTF(context->env, remote_address); - if (local != NULL && remote != NULL && - !clear_java_exception(context, "flow address allocation")) { - (*context->env)->CallVoidMethod( - context->env, - context->listener, - context->flow_opened_method, - (jlong) flow->id, - (jint) tuple->ipproto, - local, - (jint) ntohs(tuple->src_port), - remote, - (jint) ntohs(tuple->dst_port)); - clear_java_exception(context, "flow open"); - } - if (local != NULL) (*context->env)->DeleteLocalRef(context->env, local); - if (remote != NULL) (*context->env)->DeleteLocalRef(context->env, remote); + if (inet_ntop(family, &tuple->src_ip, local_address, sizeof(local_address)) == NULL || + inet_ntop(family, &tuple->dst_ip, remote_address, sizeof(remote_address)) == NULL) { + return 0; } + // A failed NewStringUTF leaves an OutOfMemoryError pending, and no further JNI call may be + // made until it is cleared. + JNIEnv *env = context->env; + jstring local = (*env)->NewStringUTF(env, local_address); + jstring remote = local == NULL ? NULL : (*env)->NewStringUTF(env, remote_address); + if (remote != NULL) { + (*env)->CallVoidMethod( + env, + context->listener, + context->flow_opened_method, + (jlong) flow->id, + (jint) tuple->ipproto, + local, + (jint) ntohs(tuple->src_port), + remote, + (jint) ntohs(tuple->dst_port)); + clear_java_exception(context, "flow open"); + (*env)->DeleteLocalRef(env, remote); + } else { + clear_java_exception(context, "flow address allocation"); + } + if (local != NULL) (*env)->DeleteLocalRef(env, local); return 0; } From 332d4bc19e365a0ec28ccf7ec799f9ebd450638f Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:52:43 +0900 Subject: [PATCH 046/125] fix: drive native purge and traffic timer from the monotonic clock --- app/src/main/cpp/gf2capture.c | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/cpp/gf2capture.c b/app/src/main/cpp/gf2capture.c index 578b6ad..ccc7df8 100644 --- a/app/src/main/cpp/gf2capture.c +++ b/app/src/main/cpp/gf2capture.c @@ -26,7 +26,7 @@ #define PACKET_BUFFER_SIZE 65535 #define VPN_MTU 1500 #define SELECT_TIMEOUT_USEC 250000 -#define PURGE_INTERVAL_SECONDS 1 +#define PURGE_INTERVAL_MILLIS 1000 #define TUN_WRITE_TIMEOUT_MILLIS 100 #define TUN_WRITE_POLL_SLICE_MILLIS 25 @@ -436,7 +436,8 @@ static void *capture_thread_main(void *argument) { goto detach; } - time_t last_purge = time(NULL); + // Monotonic, so a wall-clock adjustment cannot postpone purging or traffic updates. + int64_t last_purge = monotonic_millis(); LOGI("Native packet loop started"); while (atomic_load(&context->running)) { @@ -486,8 +487,8 @@ static void *capture_thread_main(void *argument) { zdtun_handle_fd(tunnel, &read_fds, &write_fds); } - time_t now = time(NULL); - if (now - last_purge >= PURGE_INTERVAL_SECONDS) { + const int64_t now = monotonic_millis(); + if (now - last_purge >= PURGE_INTERVAL_MILLIS) { zdtun_purge_expired(tunnel); notify_traffic(context); last_purge = now; From e62dfaa427dd97463fccad2584083f1d56637308 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:52:59 +0900 Subject: [PATCH 047/125] refactor: keep no native state for untracked flows --- app/src/main/cpp/gf2capture.c | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/app/src/main/cpp/gf2capture.c b/app/src/main/cpp/gf2capture.c index ccc7df8..bcb10ff 100644 --- a/app/src/main/cpp/gf2capture.c +++ b/app/src/main/cpp/gf2capture.c @@ -32,7 +32,6 @@ typedef struct flow_state { int64_t id; - bool tracked; bool classified; bool inspect_payload; } flow_state_t; @@ -258,21 +257,20 @@ static void protect_socket_callback(zdtun_t *tunnel, socket_t socket_fd) { static int connection_open_callback(zdtun_t *tunnel, zdtun_conn_t *connection) { capture_context_t *context = (capture_context_t *) zdtun_userdata(tunnel); + const zdtun_5tuple_t *tuple = zdtun_conn_get_5tuple(connection); + // Only TCP streams can carry game frames, so other flows keep no state and never reach Java. + if (tuple->ipproto != IPPROTO_TCP) { + return 0; + } + flow_state_t *flow = calloc(1, sizeof(flow_state_t)); if (flow == NULL) { LOGE("Unable to allocate flow state"); return 1; } - - const zdtun_5tuple_t *tuple = zdtun_conn_get_5tuple(connection); flow->id = atomic_fetch_add(&last_flow_id, 1) + 1; - // Only TCP streams can carry game frames, so other flows never reach Java. - flow->tracked = tuple->ipproto == IPPROTO_TCP; - flow->inspect_payload = flow->tracked; + flow->inspect_payload = true; zdtun_conn_set_userdata(connection, flow); - if (!flow->tracked) { - return 0; - } const int family = tuple->ipver == 4 ? AF_INET : AF_INET6; char local_address[INET6_ADDRSTRLEN] = {0}; @@ -318,7 +316,7 @@ static void connection_close_callback(zdtun_t *tunnel, const zdtun_conn_t *conne // a no-op instead of a duplicate onFlowClosed and a double free. zdtun_conn_set_userdata((zdtun_conn_t *) connection, NULL); - if (flow->tracked && context->env != NULL) { + if (context->env != NULL) { (*context->env)->CallVoidMethod( context->env, context->listener, From 755dc9713ac425772dbe391168a866d89f20c98e Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:53:48 +0900 Subject: [PATCH 048/125] fix: keep read-only early exits from discarding outer transactions --- .../PlatoonDatabaseIntegrationTest.kt | 29 +++++++++++++++++++ .../app/management/MembershipEditing.kt | 21 ++++++++------ .../app/management/PlatoonDatabaseSql.kt | 2 ++ .../app/management/SnapshotIngestion.kt | 2 +- 4 files changed, 44 insertions(+), 10 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 2b2363c..a1cbc12 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -446,6 +446,35 @@ class PlatoonDatabaseIntegrationTest { assertForeignKeysValid() } + @Test + fun rejectedNestedWritesDoNotDiscardTheOuterTransaction() { + val snapshot = PlatoonSnapshot( + id = 0, + capturedAt = Instant.parse("2026-07-31T00:00:00Z"), + sourceFile = "committed.csv", + members = listOf(member(TARGET_UID, "Committed")), + ) + val joined = MembershipBoundaryValue( + date = LocalDate.of(2026, 7, 1), + instant = Instant.parse("2026-07-01T00:00:00Z"), + timeKnown = true, + ) + + database.runInTransaction { + database.ingestSnapshot(snapshot, EvidenceSource.LEGACY_IMPORT) + assertTrue(database.ingestSnapshot(snapshot, EvidenceSource.LEGACY_IMPORT).duplicate) + assertFalse(database.addWithdrawnMember(TARGET_UID, "Again", joined, joined, "")) + assertFalse(database.addMembershipPeriod(OTHER_UID, joined, null, "")) + assertFalse(database.deleteMember(OTHER_UID)) + assertFalse(database.deleteMembershipPeriod(Long.MAX_VALUE)) + assertFalse(database.updateMembershipPeriod(Long.MAX_VALUE, joined, null, "")) + } + + assertEquals(1L, count("snapshots", "source_file = ?", "committed.csv")) + assertEquals(1L, count("members", "uid = ?", TARGET_UID)) + assertForeignKeysValid() + } + @Test fun databaseRejectsRosterBoundsForEveryIngestionCaller() { val oversizedRoster = (1..257).map { index -> member(index.toLong(), "Member $index") } 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 e4b2cc3..f348ea2 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -24,7 +24,7 @@ internal object MembershipEditing { require(name.isNotBlank()) require(isValidMembershipRange(joined, withdrew)) return db.inTransaction { - if (memberExists(db, uid)) return false + if (memberExists(db, uid)) return@inTransaction false MembershipRows.insertMember( db = db, uid = uid, @@ -50,8 +50,10 @@ internal object MembershipEditing { ): Boolean { require(isValidMembershipRange(joined, withdrew)) return db.inTransaction { - val memberName = memberName(db, uid) ?: return false - if (!canStoreMembershipPeriod(db, uid, null, joined, withdrew)) return false + val memberName = memberName(db, uid) ?: return@inTransaction false + if (!canStoreMembershipPeriod(db, uid, null, joined, withdrew)) { + return@inTransaction false + } val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, memberName, joined, withdrew) synchronizeAndVerifyMembershipState(db, uid) @@ -71,7 +73,7 @@ internal object MembershipEditing { fun deleteMember(db: SQLiteDatabase, uid: Long): Boolean { require(uid > 0) return db.inTransaction { - if (!memberExists(db, uid)) return false + if (!memberExists(db, uid)) return@inTransaction false val uidArgument = arrayOf(uid.toString()) db.delete("weekly_overrides", "uid = ?", uidArgument) db.delete( @@ -106,7 +108,7 @@ internal object MembershipEditing { "SELECT uid FROM membership_periods WHERE id = ?", arrayOf(membershipPeriodId.toString()), ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } - ?: return false + ?: return@inTransaction false val periodCount = db.rawQuery( "SELECT COUNT(*) FROM membership_periods WHERE uid = ?", arrayOf(uid.toString()), @@ -114,7 +116,8 @@ internal object MembershipEditing { check(cursor.moveToFirst()) cursor.getLong(0) } - if (periodCount <= 1L) return false + if (periodCount <= 1L) return@inTransaction false + // A vanished row after its events were detached must roll those deletions back. if (!MembershipRows.deleteMembershipPeriod(db, membershipPeriodId)) return false synchronizeAndVerifyMembershipState(db, uid) true @@ -130,13 +133,13 @@ internal object MembershipEditing { note: String, ): Boolean = db.inTransaction { val period = readMembershipPeriodForUpdate(db, membershipPeriodId, gameZone) - ?: return false + ?: return@inTransaction false // Exact packet-linked boundaries stay immutable; only editable sides take the request. val effectiveJoined = if (period.joinedSource.isImmutableMembershipBoundary()) { period.joined } else { joined - } ?: return false + } ?: return@inTransaction false val effectiveLeft = if (period.leftSource?.isImmutableMembershipBoundary() == true) { period.left } else { @@ -144,7 +147,7 @@ internal object MembershipEditing { } require(isValidMembershipRange(effectiveJoined, effectiveLeft)) if (!canStoreMembershipPeriod(db, period.uid, membershipPeriodId, effectiveJoined, effectiveLeft)) { - return false + return@inTransaction false } val joinedChanged = editableMembershipBoundaryChanged( original = period.joined, diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt index ec15516..31c9c5e 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabaseSql.kt @@ -35,6 +35,8 @@ internal enum class MembershipBoundary( /** * Commits only when [block] completes normally. An exception or a non-local `return` rolls the * transaction back; an early result that must commit returns through `return@inTransaction`. + * A nested block that rolls back discards its whole outer transaction, so read-only early exits + * also return through `return@inTransaction`. */ internal inline fun SQLiteDatabase.inTransaction(block: () -> T): T { beginTransaction() 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 c6fb1f4..5512ff5 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt @@ -34,7 +34,7 @@ internal object SnapshotIngestion { } return db.inTransaction { if (snapshot.sourceFile != null && sourceFileExists(db, snapshot.sourceFile)) { - return SnapshotIngestResult.duplicate() + return@inTransaction SnapshotIngestResult.duplicate() } val priorCapturedAt = latestSnapshotCapturedAt(db) if (historicalOnly) { From d9c13acc789a47678ce29f757ece05e522f5f389 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:53:48 +0900 Subject: [PATCH 049/125] fix: derive game days from local wall-clock time across DST --- .../gf2log/app/management/PlatoonPeriods.kt | 7 ++++- .../app/management/PlatoonPeriodsTest.kt | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt index 1eefba3..fb1b1de 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonPeriods.kt @@ -14,8 +14,13 @@ object PlatoonPeriods { const val GUNSMOKE_FINAL_SCORE_HOUR = 2 val GUNSMOKE_ANCHOR: LocalDate = LocalDate.of(2026, 7, 19) + /** + * The 05:00-based game day containing [instant]. The reset is a wall-clock time, so the + * shift is applied to the local date-time; shifting the instant would move the boundary by + * an hour on daylight-saving transition days and disagree with [periodStartInstant]. + */ fun gameDay(instant: Instant, zoneId: ZoneId): LocalDate = - instant.atZone(zoneId).minusHours(RESET_HOUR.toLong()).toLocalDate() + instant.atZone(zoneId).toLocalDateTime().minusHours(RESET_HOUR.toLong()).toLocalDate() fun weekStart(gameDay: LocalDate): LocalDate = gameDay.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt index 7739311..2e2011c 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonPeriodsTest.kt @@ -23,6 +23,32 @@ class PlatoonPeriodsTest { ) } + @Test + fun gameDayFollowsTheLocalFiveAmResetAcrossDaylightSavingTransitions() { + val newYork = ZoneId.of("America/New_York") + // 2026-03-08 springs forward at 02:00 and 2026-11-01 falls back at 02:00. + listOf( + LocalDate.of(2026, 3, 7), + LocalDate.of(2026, 3, 8), + LocalDate.of(2026, 3, 9), + LocalDate.of(2026, 10, 31), + LocalDate.of(2026, 11, 1), + LocalDate.of(2026, 11, 2), + ).forEach { day -> + val reset = PlatoonPeriods.periodStartInstant(day, newYork) + assertEquals(day, PlatoonPeriods.gameDay(reset, newYork)) + assertEquals(day.minusDays(1), PlatoonPeriods.gameDay(reset.minusMillis(1), newYork)) + } + assertEquals( + LocalDate.of(2026, 3, 8), + PlatoonPeriods.gameDay(Instant.parse("2026-03-08T09:30:00Z"), newYork), + ) + assertEquals( + LocalDate.of(2026, 10, 31), + PlatoonPeriods.gameDay(Instant.parse("2026-11-01T09:30:00Z"), newYork), + ) + } + @Test fun everyWeeklyTableRunsFromSundayThroughSaturday() { val gunsmokeSunday = LocalDate.of(2026, 7, 19) From 22311b1330687adc2604d697082ced6e946528bc Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:54:06 +0900 Subject: [PATCH 050/125] fix: discard a continued dataset when one of its frames is malformed --- .../dev/gf2log/protocol/Gfl2StreamParser.kt | 63 ++++++++++--- .../gf2log/protocol/Gfl2StreamParserTest.kt | 92 +++++++++++++++++++ 2 files changed, 143 insertions(+), 12 deletions(-) diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt index 092d0de..a632656 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt @@ -87,6 +87,10 @@ class Gfl2StreamParser( events += ParseEvent.Warning( "Message $messageId payload $type declares $payloadSize bytes with ${message.size - offset} remaining", ) + // The truncated frame is necessarily the last one in this message. + if (type in Gfl2PayloadDecoder.supportedTypes) { + discardDataset(type, messageId, isEndOfMessage = true, events) + } return } @@ -116,6 +120,7 @@ class Gfl2StreamParser( events += ParseEvent.Warning( "Unable to decode payload $type in message $messageId: ${error.message}", ) + discardDataset(type, messageId, isEndOfMessage, events) } } else { emitPending(events) @@ -142,10 +147,9 @@ class Gfl2StreamParser( ) { val pending = pendingPayload if (pending != null) { - val continues = pending.type == type && - (pending.previousMessageId == 0 || pending.previousMessageId == messageId) - val merged = if (continues) merge(pending.data, data) else null - if (continues && merged != null) { + val continued = pending.type == type && continues(pending.previousMessageId, messageId) + val merged = if (continued) merge(pending.data, data) else null + if (continued && merged != null) { val accumulatedBytes = pending.accumulatedPayloadBytes + payloadBytes.toLong() val continuationCount = pending.continuationCount + 1 if ( @@ -153,7 +157,7 @@ class Gfl2StreamParser( continuationCount > maximumPendingContinuations ) { pendingPayload = null - quarantinedPayload = if (messageId != 0 && isEndOfMessage) { + quarantinedPayload = if (isTerminal(messageId, isEndOfMessage)) { null } else { QuarantinedPayload(type, messageId) @@ -168,7 +172,7 @@ class Gfl2StreamParser( pending.isEndOfMessage = isEndOfMessage pending.accumulatedPayloadBytes = accumulatedBytes pending.continuationCount = continuationCount - if (messageId != 0 && isEndOfMessage) emitPending(events) + if (isTerminal(messageId, isEndOfMessage)) emitPending(events) return } // A different dataset, or a continuation of a single-object payload such as @@ -176,7 +180,7 @@ class Gfl2StreamParser( emitPending(events) } - if (messageId != 0 && isEndOfMessage) { + if (isTerminal(messageId, isEndOfMessage)) { events += ParseEvent.Payload(ParsedPayload(messageId, type, true, data)) } else { if (payloadBytes > maximumPendingPayloadBytes) { @@ -197,18 +201,45 @@ class Gfl2StreamParser( } } + /** + * Drops the logical dataset a malformed frame belongs to. Merging its neighbours across the + * hole would publish an incomplete roster as if it were complete, so a continued pending + * payload is discarded and the dataset's remaining fragments are quarantined. A payload that + * cannot be merged, such as 21905, owns each frame and keeps its earlier, valid frames. + */ + private fun discardDataset( + type: Int, + messageId: Int, + isEndOfMessage: Boolean, + events: MutableList, + ) { + val mergeable = type != Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE + val pending = pendingPayload + if ( + mergeable && pending != null && pending.type == type && + continues(pending.previousMessageId, messageId) + ) { + pendingPayload = null + } else { + emitPending(events) + } + if (!mergeable) return + quarantinedPayload = if (isTerminal(messageId, isEndOfMessage)) { + null + } else { + QuarantinedPayload(type, messageId) + } + } + private fun consumeQuarantinedPayload( type: Int, messageId: Int, isEndOfMessage: Boolean, ): Boolean { val quarantined = quarantinedPayload ?: return false - if ( - quarantined.type == type && - (quarantined.previousMessageId == 0 || quarantined.previousMessageId == messageId) - ) { + if (quarantined.type == type && continues(quarantined.previousMessageId, messageId)) { quarantined.previousMessageId = messageId - if (messageId != 0 && isEndOfMessage) quarantinedPayload = null + if (isTerminal(messageId, isEndOfMessage)) quarantinedPayload = null return true } quarantinedPayload = null @@ -249,6 +280,14 @@ class Gfl2StreamParser( else -> null } + /** The reference continuation rule: after message id 0, or within the same message id. */ + private fun continues(previousMessageId: Int, messageId: Int): Boolean = + previousMessageId == 0 || previousMessageId == messageId + + /** A frame with a non-zero message id that ends its message completes a logical payload. */ + private fun isTerminal(messageId: Int, isEndOfMessage: Boolean): Boolean = + messageId != 0 && isEndOfMessage + private fun readUInt16Le(bytes: ByteArray, offset: Int): Int = (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8) diff --git a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt index aaaf193..1a4b947 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/Gfl2StreamParserTest.kt @@ -388,6 +388,83 @@ class Gfl2StreamParserTest { assertEquals("Recovered", (recovered.value.data as GuildMembersData).members.single().name) } + @Test + fun malformedContinuationDiscardsTheWholeDatasetInsteadOfMergingAroundIt() { + val parser = Gfl2StreamParser() + val first = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("First", 1uL)), + ) + val malformed = outerMessage(0, payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, MALFORMED)) + val last = outerMessage( + 42, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("Last", 3uL)), + ) + + assertTrue(parser.accept(first).isEmpty()) + assertEquals(1, parser.accept(malformed).filterIsInstance().size) + assertTrue(parser.accept(last).isEmpty()) + assertTrue(parser.finish().isEmpty()) + assertRecovers(parser) + } + + @Test + fun malformedFirstFragmentQuarantinesItsTail() { + val parser = Gfl2StreamParser() + val events = parser.accept( + outerMessage(0, payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, MALFORMED)) + + outerMessage( + 42, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("Tail", 2uL)), + ), + ) + + assertEquals(1, events.filterIsInstance().size) + assertTrue(events.filterIsInstance().isEmpty()) + assertRecovers(parser) + } + + @Test + fun truncatedContinuationFrameDiscardsTheWholeDataset() { + val parser = Gfl2StreamParser() + val first = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("First", 1uL)), + ) + val truncated = outerMessageWithBody( + 0, + littleEndian16(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) + littleEndian16(100) + byteArrayOf(0x0A), + ) + val last = outerMessage( + 42, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("Last", 3uL)), + ) + + assertTrue(parser.accept(first).isEmpty()) + val events = parser.accept(truncated + last) + + assertEquals(1, events.filterIsInstance().size) + assertTrue(events.filterIsInstance().isEmpty()) + assertRecovers(parser) + } + + @Test + fun malformedProfileFrameKeepsTheEarlierIndependentProfile() { + val parser = Gfl2StreamParser() + val valid = outerMessage( + 0, + payload(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, platoonProfilePayload(11uL, "Kept")), + ) + val malformed = outerMessage(0, payload(Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE, MALFORMED)) + + assertTrue(parser.accept(valid).isEmpty()) + val events = parser.accept(malformed) + + assertEquals(1, events.filterIsInstance().size) + assertEquals("Kept", (events.singlePayload().value.data as PlatoonProfileData).platoonName) + assertTrue(parser.finish().isEmpty()) + } + @Test fun unknownPayloadsRemainObservableWithoutRetainingTheirBytes() { val parser = Gfl2StreamParser() @@ -488,6 +565,16 @@ class Gfl2StreamParserTest { return messageField(1, entry) } + private fun assertRecovers(parser: Gfl2StreamParser) { + val recovered = parser.accept( + outerMessage( + 43, + payload(Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, guildMembersPayload("Fresh", 9uL)), + ), + ).singlePayload() + assertEquals("Fresh", (recovered.value.data as GuildMembersData).members.single().name) + } + private fun List.singlePayload(): ParseEvent.Payload = filterIsInstance().single() @@ -538,4 +625,9 @@ class Gfl2StreamParserTest { value.toByte(), (value ushr 8).toByte(), ) + + private companion object { + /** Field 1 declares 127 bytes that are not present. */ + val MALFORMED = byteArrayOf(0x0A, 0x7F) + } } From 394388f5c29bf5a2e2461be6f5e18d57509588e2 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:54:29 +0900 Subject: [PATCH 051/125] fix: export the current membership period in member CSV --- .../gf2log/app/management/PlatoonMemberCsv.kt | 2 +- .../gf2log/app/management/PlatoonModels.kt | 13 +++++ .../app/management/PlatoonMemberCsvTest.kt | 55 +++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt index b974417..b106712 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonMemberCsv.kt @@ -19,7 +19,7 @@ object PlatoonMemberCsv { appendLine(HEADER) statuses.forEach { status -> val latest = latestMembers[status.uid] - val membershipPeriod = status.membershipPeriods.firstOrNull() + val membershipPeriod = status.latestMembershipPeriod() appendLine( listOf( status.uid, 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 a046535..6107630 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt @@ -51,6 +51,19 @@ data class MemberStatus( val membershipPeriods: List, ) +/** + * 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. + */ +fun MemberStatus.latestMembershipPeriod(): MembershipPeriod? { + val open = membershipPeriods.filter { it.leftAt == null } + return (open.ifEmpty { membershipPeriods }).maxWithOrNull( + compareBy(nullsFirst()) { it.leftAt } + .thenBy(nullsFirst()) { it.joinedAt } + .thenBy { it.id }, + ) +} + data class MemberEvent( val id: Long, val uid: Long, diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonMemberCsvTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonMemberCsvTest.kt index d91f23c..3412af4 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonMemberCsvTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonMemberCsvTest.kt @@ -3,6 +3,7 @@ package dev.gf2log.app.management import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -46,6 +47,33 @@ class PlatoonMemberCsvTest { assertTrue(csv.contains("2026-01-01 09:00:00,2026-07-21 09:00:00")) } + @Test + fun exportsTheCurrentPeriodOfARejoinedMemberAndTheLatestPeriodOfAWithdrawnMember() { + val first = period(id = 1, joined = "2026-01-01T00:00:00Z", left = "2026-02-01T00:00:00Z") + val second = period(id = 2, joined = "2026-03-01T00:00:00Z", left = null) + val unknownStart = period(id = 3, joined = null, left = "2025-12-01T00:00:00Z") + val rejoined = status(uid = 10, active = true, periods = listOf(first, second, unknownStart)) + val withdrawn = status( + uid = 11, + active = false, + periods = listOf( + first, + period(id = 4, joined = "2026-03-01T00:00:00Z", left = "2026-04-01T00:00:00Z"), + unknownStart, + ), + ) + + val rows = PlatoonMemberCsv.format( + listOf(rejoined, withdrawn), + latestMembers = emptyMap(), + zoneId = ZoneId.of("UTC"), + ).lines() + + assertTrue(rows[1].endsWith(",2026-03-01 00:00:00,,")) + assertTrue(rows[2].endsWith(",2026-03-01 00:00:00,2026-04-01 00:00:00,")) + assertEquals(second, rejoined.latestMembershipPeriod()) + } + @Test fun exportsDateOnlyManualBoundariesWithoutInventingMidnightTimes() { val status = MemberStatus( @@ -107,4 +135,31 @@ class PlatoonMemberCsvTest { assertTrue(csv.contains("\"'=HYPERLINK(\"\"https://invalid\"\")\"")) assertTrue(csv.contains(",'+cmd")) } + + private fun status(uid: Long, active: Boolean, periods: List) = MemberStatus( + uid = uid, + name = "Member $uid", + level = 60, + isActive = active, + firstSeenAt = Instant.parse("2025-12-01T00:00:00Z"), + lastSeenAt = Instant.parse("2026-04-01T00:00:00Z"), + note = "", + membershipPeriods = periods, + ) + + private fun period(id: Long, joined: String?, left: String?) = MembershipPeriod( + id = id, + uid = 0, + joinedAt = joined?.let(Instant::parse), + leftAt = left?.let(Instant::parse), + joinedDate = null, + leftDate = null, + joinedTimeKnown = true, + leftTimeKnown = left?.let { true }, + joinedPrecision = EvidencePrecision.INFERRED, + leftPrecision = left?.let { EvidencePrecision.INFERRED }, + joinedSource = EvidenceSource.SNAPSHOT, + leftSource = left?.let { EvidenceSource.SNAPSHOT }, + note = "", + ) } From 1f108d5fa95409f82f92aa3d9f6d16212e2baedc Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:55:12 +0900 Subject: [PATCH 052/125] refactor: parse stream frames in place without per-message copies --- .../dev/gf2log/protocol/Gfl2PayloadDecoder.kt | 14 ++++ .../dev/gf2log/protocol/Gfl2StreamParser.kt | 80 +++++++------------ .../protocol/internal/ByteAccumulator.kt | 44 ++++------ .../dev/gf2log/protocol/PayloadCatalogTest.kt | 7 ++ .../protocol/internal/ByteAccumulatorTest.kt | 36 +++++++++ 5 files changed, 103 insertions(+), 78 deletions(-) create mode 100644 protocol/src/test/java/dev/gf2log/protocol/internal/ByteAccumulatorTest.kt diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt index 54c653e..8260c70 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2PayloadDecoder.kt @@ -45,6 +45,20 @@ object Gfl2PayloadDecoder { TYPE_FORMATIONS, ) + /** Allocation-free equivalent of `type in supportedTypes` for the per-frame hot path. */ + fun isSupported(type: Int): Boolean = when (type) { + TYPE_WEAPONS, + TYPE_WEAPON_MODS, + TYPE_PUBLIC_SKILL_ITEMS, + TYPE_PLATOON_PROFILE, + TYPE_GUILD_MEMBERS, + TYPE_PLATOON_ACTIVITY, + TYPE_PLATOON_UPDATES, + TYPE_FORMATIONS, + -> true + else -> false + } + @Throws(ProtocolException::class) fun decode(type: Int, bytes: ByteArray): GameData? = when (type) { TYPE_WEAPONS -> decodeWeapons(ProtoReader(bytes)) diff --git a/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt b/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt index a632656..4a76063 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/Gfl2StreamParser.kt @@ -36,18 +36,13 @@ class Gfl2StreamParser( } while (buffer.available >= OUTER_HEADER_SIZE) { - val messageId = buffer.peekUInt24Le(0) + // A 16-bit body length can never exceed one bounded outer message. val messageSize = buffer.peekUInt16Le(3) + OUTER_HEADER_SIZE - - if (messageSize > MAXIMUM_MESSAGE_SIZE) { - buffer.discard(1) - events += ParseEvent.Warning("Discarded an invalid message header declaring $messageSize bytes") - continue - } if (buffer.available < messageSize) break - val message = buffer.read(messageSize) - parseMessage(messageId, message, events) + // Parsed in place: decoding copies only recognized payload bodies. + parseMessage(buffer.peekUInt24Le(0), buffer.storage, buffer.readOffset, messageSize, events) + buffer.discard(messageSize) } return events @@ -69,60 +64,35 @@ class Gfl2StreamParser( private fun parseMessage( messageId: Int, - message: ByteArray, + bytes: ByteArray, + start: Int, + size: Int, events: MutableList, ) { - var offset = OUTER_HEADER_SIZE - while (offset < message.size) { - if (message.size - offset < PAYLOAD_HEADER_SIZE) { + val end = start + size + var offset = start + OUTER_HEADER_SIZE + while (offset < end) { + if (end - offset < PAYLOAD_HEADER_SIZE) { events += ParseEvent.Warning( "Message $messageId ends with an incomplete payload header", ) return } - val type = readUInt16Le(message, offset) - val payloadSize = readUInt16Le(message, offset + 2) + PAYLOAD_HEADER_SIZE - if (payloadSize > message.size - offset) { + val type = readUInt16Le(bytes, offset) + val payloadSize = readUInt16Le(bytes, offset + 2) + PAYLOAD_HEADER_SIZE + val supported = Gfl2PayloadDecoder.isSupported(type) + if (payloadSize > end - offset) { events += ParseEvent.Warning( - "Message $messageId payload $type declares $payloadSize bytes with ${message.size - offset} remaining", + "Message $messageId payload $type declares $payloadSize bytes with ${end - offset} remaining", ) // The truncated frame is necessarily the last one in this message. - if (type in Gfl2PayloadDecoder.supportedTypes) { - discardDataset(type, messageId, isEndOfMessage = true, events) - } + if (supported) discardDataset(type, messageId, isEndOfMessage = true, events) return } - val isEndOfMessage = offset + payloadSize >= message.size - if (type in Gfl2PayloadDecoder.supportedTypes) { - if (consumeQuarantinedPayload(type, messageId, isEndOfMessage)) { - offset += payloadSize - continue - } - val payload = message.copyOfRange( - offset + PAYLOAD_HEADER_SIZE, - offset + payloadSize, - ) - try { - val decoded = Gfl2PayloadDecoder.decode(type, payload) - if (decoded != null) { - acceptDecodedPayload( - messageId, - type, - isEndOfMessage, - payload.size, - decoded, - events, - ) - } - } catch (error: ProtocolException) { - events += ParseEvent.Warning( - "Unable to decode payload $type in message $messageId: ${error.message}", - ) - discardDataset(type, messageId, isEndOfMessage, events) - } - } else { + val isEndOfMessage = offset + payloadSize >= end + if (!supported) { emitPending(events) quarantinedPayload = null events += ParseEvent.UnknownPayload( @@ -131,6 +101,17 @@ class Gfl2StreamParser( payloadBytes = payloadSize - PAYLOAD_HEADER_SIZE, isEndOfMessage = isEndOfMessage, ) + } else if (!consumeQuarantinedPayload(type, messageId, isEndOfMessage)) { + val payload = bytes.copyOfRange(offset + PAYLOAD_HEADER_SIZE, offset + payloadSize) + try { + val decoded = requireNotNull(Gfl2PayloadDecoder.decode(type, payload)) + acceptDecodedPayload(messageId, type, isEndOfMessage, payload.size, decoded, events) + } catch (error: ProtocolException) { + events += ParseEvent.Warning( + "Unable to decode payload $type in message $messageId: ${error.message}", + ) + discardDataset(type, messageId, isEndOfMessage, events) + } } offset += payloadSize @@ -295,7 +276,6 @@ class Gfl2StreamParser( private companion object { const val OUTER_HEADER_SIZE = 5 const val PAYLOAD_HEADER_SIZE = 4 - const val MAXIMUM_MESSAGE_SIZE = 65_540 const val DEFAULT_MAXIMUM_BUFFERED_BYTES = 2 * 1024 * 1024 const val DEFAULT_MAXIMUM_PENDING_PAYLOAD_BYTES = 2 * 1024 * 1024 const val DEFAULT_MAXIMUM_PENDING_CONTINUATIONS = 64 diff --git a/protocol/src/main/java/dev/gf2log/protocol/internal/ByteAccumulator.kt b/protocol/src/main/java/dev/gf2log/protocol/internal/ByteAccumulator.kt index 4c424d6..a5c6ed2 100644 --- a/protocol/src/main/java/dev/gf2log/protocol/internal/ByteAccumulator.kt +++ b/protocol/src/main/java/dev/gf2log/protocol/internal/ByteAccumulator.kt @@ -12,13 +12,20 @@ internal class ByteAccumulator( val available: Int get() = writePosition - readPosition + /** Backing array holding [available] unread bytes from [readOffset]; valid until mutated. */ + val storage: ByteArray + get() = bytes + + val readOffset: Int + get() = readPosition + fun append(source: ByteArray) { if (source.isEmpty()) return if (source.size > maximumSize - available) { throw ProtocolException("Buffered stream data exceeds $maximumSize bytes") } - ensureCapacity(available + source.size) + ensureCapacity(source.size) source.copyInto(bytes, writePosition) writePosition += source.size } @@ -38,18 +45,10 @@ internal class ByteAccumulator( ((bytes[position + 2].toInt() and 0xFF) shl 16) } - fun read(count: Int): ByteArray { - requireAvailable(0, count) - val result = bytes.copyOfRange(readPosition, readPosition + count) - readPosition += count - compactIfNeeded() - return result - } - fun discard(count: Int) { requireAvailable(0, count) readPosition += count - compactIfNeeded() + if (readPosition == writePosition) clear() } fun clear() { @@ -57,20 +56,17 @@ internal class ByteAccumulator( writePosition = 0 } - private fun ensureCapacity(required: Int) { - if (required <= bytes.size - readPosition) { - if (readPosition > 0) compact() + /** Compacts or grows only when [extra] bytes do not fit after the current write position. */ + private fun ensureCapacity(extra: Int) { + if (writePosition + extra <= bytes.size) return + val required = available + extra + if (required <= bytes.size) { + compact() return } var newSize = bytes.size - while (newSize < required) { - newSize = minOf(maximumSize, newSize * 2) - if (newSize < required && newSize == maximumSize) { - throw ProtocolException("Unable to grow stream buffer to $required bytes") - } - } - + while (newSize < required) newSize = minOf(maximumSize, newSize * 2) val replacement = ByteArray(newSize) bytes.copyInto(replacement, 0, readPosition, writePosition) writePosition = available @@ -78,14 +74,6 @@ internal class ByteAccumulator( bytes = replacement } - private fun compactIfNeeded() { - if (readPosition == writePosition) { - clear() - } else if (readPosition >= bytes.size / 2) { - compact() - } - } - private fun compact() { val length = available bytes.copyInto(bytes, 0, readPosition, writePosition) diff --git a/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt b/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt index d300913..e28628e 100644 --- a/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt +++ b/protocol/src/test/java/dev/gf2log/protocol/PayloadCatalogTest.kt @@ -6,6 +6,13 @@ import org.junit.Assert.assertTrue import org.junit.Test class PayloadCatalogTest { + @Test + fun `hot path support check matches the supported type set`() { + (0..0xFFFF).forEach { type -> + assertEquals(type in Gfl2PayloadDecoder.supportedTypes, Gfl2PayloadDecoder.isSupported(type)) + } + } + @Test fun `catalog names every supported payload type`() { assertEquals(Gfl2PayloadDecoder.supportedTypes, PayloadCatalog.categories.map { it.payloadType }.toSet()) diff --git a/protocol/src/test/java/dev/gf2log/protocol/internal/ByteAccumulatorTest.kt b/protocol/src/test/java/dev/gf2log/protocol/internal/ByteAccumulatorTest.kt new file mode 100644 index 0000000..c3babcb --- /dev/null +++ b/protocol/src/test/java/dev/gf2log/protocol/internal/ByteAccumulatorTest.kt @@ -0,0 +1,36 @@ +package dev.gf2log.protocol.internal + +import dev.gf2log.protocol.ProtocolException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ByteAccumulatorTest { + @Test + fun compactsAndGrowsWithoutLosingUnreadBytes() { + val buffer = ByteAccumulator(maximumSize = 1_024) + buffer.append(ByteArray(200) { it.toByte() }) + buffer.discard(150) + // 50 unread + 100 new fits the initial 256-byte array only after compaction. + buffer.append(ByteArray(100) { (200 + it).toByte() }) + assertEquals(150, buffer.available) + assertEquals(150 or (151 shl 8), buffer.peekUInt16Le(0)) + + buffer.append(ByteArray(600) { 7 }) + assertEquals(750, buffer.available) + assertEquals(150, buffer.storage[buffer.readOffset].toInt() and 0xFF) + assertEquals(7, buffer.storage[buffer.readOffset + 749].toInt()) + + buffer.discard(750) + assertEquals(0, buffer.available) + assertEquals(0, buffer.readOffset) + } + + @Test + fun rejectsInputBeyondTheBound() { + val buffer = ByteAccumulator(maximumSize = 64) + buffer.append(ByteArray(60)) + assertTrue(runCatching { buffer.append(ByteArray(5)) }.exceptionOrNull() is ProtocolException) + assertEquals(60, buffer.available) + } +} From 42937021f7a5e88380f0fbd6f30ef30aec710eac Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:55:37 +0900 Subject: [PATCH 053/125] fix: quarantine a bound flow that reports an unregistered Platoon --- .../java/dev/gf2log/app/capture/CaptureFlowEngine.kt | 10 +++++----- .../dev/gf2log/app/capture/PlatoonProfilePolicy.kt | 8 ++++++++ .../dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt | 10 ++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) 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 99bb3e2..760f763 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt @@ -237,16 +237,16 @@ internal class CaptureFlowEngine( if (admission.claimsIdentity(flowId, ownerPackage, data)) return val known = findKnownProfile(client, ownerPackage, data) - if (known == null) { - admission.begin(flowId, ownerPackage, data, preIdentity.take(flowId)) - return - } val current = sessions[flowId] - if (current != null && current.profile.storageId != known.storageId) { + if (PlatoonProfilePolicy.changesBoundIdentity(current?.profile?.storageId, known?.storageId)) { quarantine(flowId) CaptureStatus.report(CaptureNotice.IDENTITY_CHANGED) return } + if (known == null) { + admission.begin(flowId, ownerPackage, data, preIdentity.take(flowId)) + return + } val profile = observe(known.storageId, data) if (profile == null) { quarantine(flowId) diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt index 4d44ff3..4206df7 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt @@ -12,6 +12,14 @@ internal object PlatoonProfilePolicy { profile.platoonName.length <= MAX_NAME_LENGTH && profile.platoonName.none(Char::isISOControl) + /** + * A flow bound to [boundStorageId] may only keep reporting that scope. Any other identity, + * including one that is not registered yet, is a change: admitting it while the flow stays + * bound would route the new Platoon's payloads into the old scope. + */ + fun changesBoundIdentity(boundStorageId: String?, observedStorageId: String?): Boolean = + boundStorageId != null && boundStorageId != observedStorageId + /** One user-started capture may persist at most one new profile per supported client. */ internal class AdmissionGate { private val admittedClients = mutableSetOf() diff --git a/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt b/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt index 03b6c72..20448df 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PlatoonProfilePolicyTest.kt @@ -16,6 +16,16 @@ class PlatoonProfilePolicyTest { assertTrue(PlatoonProfilePolicy.isValid(profile(101817u, "Owls"))) } + @Test + fun boundFlowCannotSwitchToAnotherOrUnregisteredPlatoon() { + assertFalse(PlatoonProfilePolicy.changesBoundIdentity(null, null)) + assertFalse(PlatoonProfilePolicy.changesBoundIdentity(null, "known")) + assertFalse(PlatoonProfilePolicy.changesBoundIdentity("known", "known")) + assertTrue(PlatoonProfilePolicy.changesBoundIdentity("known", "other")) + // An unregistered identity must not start admission while the flow stays bound. + assertTrue(PlatoonProfilePolicy.changesBoundIdentity("known", null)) + } + @Test fun admissionGateAllowsOneNewProfilePerClientAndAlwaysAllowsKnownProfiles() { val gate = PlatoonProfilePolicy.AdmissionGate() From 6c0090589a2a1f2f970acd8d442a680bf0810c26 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:55:46 +0900 Subject: [PATCH 054/125] fix: bound Updates member names and member lists before persistence --- .../management/PlatoonObservationPolicy.kt | 8 ++++- .../PlatoonObservationPolicyTest.kt | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonObservationPolicy.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonObservationPolicy.kt index 2912c0b..15ca84c 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonObservationPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonObservationPolicy.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.model.PlatoonActivityData import dev.gf2log.protocol.model.PlatoonUpdatesData import java.time.Instant @@ -9,6 +10,10 @@ internal object PlatoonObservationPolicy { const val MAX_UPDATE_OBSERVATIONS = 250 const val MAX_ACTIVITY_MEMBER_NAME_LENGTH = 128 + /** An Updates name becomes a member and report name, so it obeys the roster bound. */ + const val MAX_UPDATE_MEMBER_NAME_LENGTH = GuildMembersCsv.MAX_MEMBER_NAME_CHARS + const val MAX_UPDATE_MEMBERS_PER_OBSERVATION = GuildMembersCsv.MAX_ROSTER_MEMBERS + fun activity(data: PlatoonActivityData): List = activity( data.entries.mapNotNull { entry -> if ( @@ -68,7 +73,8 @@ internal object PlatoonObservationPolicy { observations: List, ): List = observations.asSequence() .filter { - it.members.isNotEmpty() && + it.members.size in 1..MAX_UPDATE_MEMBERS_PER_OBSERVATION && + it.members.all { member -> member.name.length <= MAX_UPDATE_MEMBER_NAME_LENGTH } && PlatoonUpdateSemantics.effect(it.kind) != PlatoonUpdateEffect.IGNORE } .distinctBy { observation -> diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt index 1784c4c..ec4a0fc 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt @@ -8,6 +8,7 @@ import dev.gf2log.protocol.model.PlatoonUpdatesData import java.time.Instant import java.time.ZoneOffset import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class PlatoonObservationPolicyTest { @@ -132,6 +133,37 @@ class PlatoonObservationPolicyTest { ) } + @Test + fun `updates reject names and member lists that persisted rows cannot represent`() { + val valid = update(members = listOf(member(1))) + val oversizedName = update( + kind = PlatoonUpdateSemantics.KIND_REMOVED, + occurredAt = Instant.parse("2026-07-31T00:00:01Z"), + members = listOf( + member(2), + member(3).copy( + name = "x".repeat(PlatoonObservationPolicy.MAX_UPDATE_MEMBER_NAME_LENGTH + 1), + ), + ), + ) + val oversizedEntry = update( + occurredAt = Instant.parse("2026-07-31T00:00:02Z"), + members = (1L..PlatoonObservationPolicy.MAX_UPDATE_MEMBERS_PER_OBSERVATION + 1L) + .map(::member), + ) + + assertEquals( + listOf(valid), + PlatoonObservationPolicy.updates(listOf(valid, oversizedName, oversizedEntry)), + ) + // Every accepted name stays encodable as a weekly-history member name (UTF-8 <= 3 bytes + // per UTF-16 unit). + assertTrue( + PlatoonObservationPolicy.MAX_UPDATE_MEMBER_NAME_LENGTH * 3 <= + WeeklyReportHistoryCodec.MAX_STRING_BYTES, + ) + } + @Test fun `weekly history work is capped after distinct week mapping`() { val zone = ZoneOffset.UTC From 43e27c954b84048dfc4382a43282cc294fe0351b Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:55:46 +0900 Subject: [PATCH 055/125] fix: render the live weekly table when history recording fails --- .../PlatoonRepositoryIntegrationTest.kt | 84 +++++++++++++++++++ .../app/management/PlatoonRepository.kt | 11 ++- 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt new file mode 100644 index 0000000..ba26b0c --- /dev/null +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt @@ -0,0 +1,84 @@ +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 dev.gf2log.protocol.model.GuildMember +import java.io.File +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlatoonRepositoryIntegrationTest { + private val context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() = clearState() + + @After + fun tearDown() = clearState() + + @Test + fun weeklyScreenStillRendersARevisionThatHistoryCannotEncode() { + val repository = PlatoonRepository(context, SCOPE) + val capturedAt = Instant.parse("2026-08-03T12:00:00Z") + repository.ingest(capturedAt, listOf(member(UID, "Short")), "roster.csv") + // A pre-bound Updates row can carry a name longer than the history codec accepts. + val oversizedName = "x".repeat(WeeklyReportHistoryCodec.MAX_STRING_BYTES + 1) + PlatoonRepository.withExclusiveDatabase(SCOPE) { + PlatoonDatabase(context, SCOPE.databaseName).use { database -> + database.writableDatabase.insertOrThrow( + "member_events", + null, + ContentValues().apply { + put("uid", UID) + put("event_type", MemberEventType.REJOINED.name) + put("occurred_at", capturedAt.plusSeconds(60).toEpochMilli()) + put("observed_at", capturedAt.plusSeconds(60).toEpochMilli()) + put("precision", EvidencePrecision.EXACT.name) + put("source", EvidenceSource.GAME_UPDATES.name) + put("note", oversizedName) + }, + ) + } + } + + val revision = repository.buildWeeklyTableRevision( + referenceDay = LocalDate.of(2026, 8, 3), + zoneId = ZoneOffset.UTC, + asOf = capturedAt.plusSeconds(3_600), + ) + + assertEquals(oversizedName, revision.report.members.single().name) + } + + private fun member(uid: Long, name: String) = GuildMember( + uid = uid.toUInt(), + name = name, + level = 1u, + weeklyMerit = 0u, + totalMerit = 0u, + highScore = 0u, + totalScore = 0u, + lastLogin = 0u, + ) + + private fun clearState() { + PlatoonRepository.withExclusiveDatabase(SCOPE) { + context.deleteDatabase(SCOPE.databaseName) + } + File(context.filesDir, "platoons/${SCOPE.storageId}").deleteRecursively() + } + + private companion object { + val SCOPE = PlatoonStorageScope("1".repeat(32)) + const val UID = 4242L + } +} 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 d293738..94972f2 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -276,8 +276,9 @@ internal class PlatoonRepository( /** * The weekly screen's load: returns the displayed revision and records the live projection - * it was derived from, so the history list includes what the user saw. A stored selection - * that no longer decodes is cleared and the live projection is shown instead. + * it was derived from, so the history list includes what the user saw. History is a derived + * audit trail, so a revision it cannot record still renders. A stored selection that no + * longer decodes is cleared and the live projection is shown instead. */ fun buildWeeklyTableRevision( referenceDay: LocalDate, @@ -285,7 +286,11 @@ internal class PlatoonRepository( asOf: Instant = Instant.now(), ): WeeklyTableRevision { val live = buildLiveWeeklyRevision(referenceDay, zoneId, asOf) - recordHistory(live, Instant.now(), clearActiveOnChange = false) + try { + recordHistory(live, Instant.now(), clearActiveOnChange = false) + } catch (error: Exception) { + Log.e(TAG, "Unable to record weekly history for ${live.report.periodStart}", error) + } return activeRevision(live) ?: live.also { access { database -> database.clearActiveWeeklyReportHistory(live.report.periodStart.toEpochDay()) From 3676989fde72f8ab997e0eabe6fe7ce30d39206a Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:56:35 +0900 Subject: [PATCH 056/125] fix: reject weekly notes and overrides outside their week --- .../PlatoonDatabaseIntegrationTest.kt | 32 +++++++++++++++++++ .../app/management/WeeklyAnnotationStore.kt | 16 +++++++++- .../gf2log/app/management/WeeklyNotePolicy.kt | 10 ++++++ .../app/management/WeeklyNotePolicyTest.kt | 12 +++++++ 4 files changed, 69 insertions(+), 1 deletion(-) 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 a1cbc12..92aab88 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -1283,6 +1283,38 @@ class PlatoonDatabaseIntegrationTest { ) } + @Test + fun weeklyAnnotationsOutsideTheirWeekAreRejectedBeforePersistence() { + val periodStart = LocalDate.of(2026, 8, 16) + val override = WeeklyCellOverride( + uid = TARGET_UID, + periodStart = periodStart, + gameDay = periodStart, + meritDelta = 50, + scoreDelta = null, + attempts = null, + attended = true, + dailyPatrol = null, + ) + + assertThrows(IllegalArgumentException::class.java) { + database.addWeeklyNote(periodStart.toEpochDay(), periodStart.plusDays(7).toEpochDay(), "Late") + } + assertThrows(IllegalArgumentException::class.java) { + database.replaceWeeklyOverrides( + periodStart.toEpochDay(), + listOf(override.copy(gameDay = periodStart.minusDays(1))), + ) + } + assertThrows(IllegalArgumentException::class.java) { + database.replaceWeeklyOverrides(periodStart.toEpochDay(), listOf(override, override)) + } + database.replaceWeeklyOverrides(periodStart.toEpochDay(), listOf(override)) + + assertTrue(database.listWeeklyNotes(periodStart.toEpochDay()).isEmpty()) + assertEquals(listOf(override), database.listWeeklyOverrides(periodStart.toEpochDay())) + } + private fun update(kind: Long, at: Instant, uid: Long, name: String) = PlatoonUpdateObservation( kind = kind, diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt index 5761ad3..cf7faf4 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt @@ -13,6 +13,12 @@ internal object WeeklyAnnotationStore { text: String, ): Long { require(text.isNotBlank()) + require( + WeeklyNotePolicy.isWithinWeek( + LocalDate.ofEpochDay(periodStartEpochDay), + LocalDate.ofEpochDay(gameDayEpochDay), + ), + ) { "A weekly note must belong to its week" } val manualNoteCount = db.rawQuery( "SELECT COUNT(*) FROM weekly_notes WHERE period_start = ? AND is_automatic = 0", arrayOf(periodStartEpochDay.toString()), @@ -115,7 +121,15 @@ internal object WeeklyAnnotationStore { periodStartEpochDay: Long, overrides: List, ) { - require(overrides.all { it.periodStart.toEpochDay() == periodStartEpochDay }) + require( + overrides.all { + it.periodStart.toEpochDay() == periodStartEpochDay && + WeeklyNotePolicy.isWithinWeek(it.periodStart, it.gameDay) + }, + ) { "A weekly override must belong to its week" } + require(overrides.distinctBy { it.uid to it.gameDay }.size == overrides.size) { + "A weekly cell can have only one override" + } require( overrides.all { it.attempts == null || it.attempts in 0..ActivityInference.MAX_DAILY_ATTEMPTS diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt index 27cac15..c5a9aea 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyNotePolicy.kt @@ -1,11 +1,21 @@ package dev.gf2log.app.management +import java.time.LocalDate + /** Keeps live weekly notes representable by the immutable weekly-history format. */ internal object WeeklyNotePolicy { const val MAX_MANUAL_NOTES_PER_WEEK = WeeklyReportHistoryCodec.MAX_NOTES fun canAdd(manualNoteCount: Int): Boolean = manualNoteCount in 0 until MAX_MANUAL_NOTES_PER_WEEK + + /** + * True when [gameDay] lies in the Sunday-based week starting at [periodStart]. Weekly notes + * and cell overrides outside their week would fail every later revision audit of that week. + */ + fun isWithinWeek(periodStart: LocalDate, gameDay: LocalDate): Boolean = + PlatoonPeriods.weekStart(periodStart) == periodStart && + gameDay in periodStart..periodStart.plusDays(6) } /** Signals the one user-correctable weekly-note capacity failure. */ diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt index 2fe3146..2195cbe 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyNotePolicyTest.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import java.time.LocalDate import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -12,4 +13,15 @@ class WeeklyNotePolicyTest { assertFalse(WeeklyNotePolicy.canAdd(128)) assertFalse(WeeklyNotePolicy.canAdd(-1)) } + + @Test + fun acceptsOnlyDaysInsideTheirSundayBasedWeek() { + val sunday = LocalDate.of(2026, 8, 16) + + assertTrue(WeeklyNotePolicy.isWithinWeek(sunday, sunday)) + assertTrue(WeeklyNotePolicy.isWithinWeek(sunday, sunday.plusDays(6))) + assertFalse(WeeklyNotePolicy.isWithinWeek(sunday, sunday.minusDays(1))) + assertFalse(WeeklyNotePolicy.isWithinWeek(sunday, sunday.plusDays(7))) + assertFalse(WeeklyNotePolicy.isWithinWeek(sunday.plusDays(1), sunday.plusDays(1))) + } } From f41cb8ffc87f97509ce79f59d896d8a4a439b7f0 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:56:47 +0900 Subject: [PATCH 057/125] fix: release a pending Platoon claim when confirmation fails unexpectedly --- .../capture/PendingPlatoonAdmissionStore.kt | 17 +++++++ .../capture/PlatoonAdmissionCoordinator.kt | 47 ++++++++++++------- .../PendingPlatoonAdmissionStoreTest.kt | 28 +++++++++++ 3 files changed, 75 insertions(+), 17 deletions(-) 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 88dfc80..68119c6 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStore.kt @@ -178,6 +178,23 @@ internal object PendingPlatoonAdmissionStore { @Synchronized fun complete(token: String): Set = removeLocked(token)?.flowIds.orEmpty() + /** + * Claims [token] and runs [admit] outside the lock. The candidate is completed when [admit] + * returns true and released for another decision otherwise, including when it throws, so a + * failed confirmation can never leave a candidate claimed and invisible forever. + * Returns the completed candidate's flow IDs, or null when nothing was completed. + */ + fun withClaim(token: String, admit: (Claim) -> Boolean): Set? { + val claim = claim(token) ?: return null + var admitted = false + try { + admitted = admit(claim) + } finally { + if (!admitted) releaseClaim(token) + } + return if (admitted) complete(token) else null + } + @Synchronized fun discard(token: String): Set = removeLocked(token)?.flowIds.orEmpty() 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 5ee471b..a0e915a 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt @@ -83,12 +83,27 @@ internal class PlatoonAdmissionCoordinator( } fun confirm(token: String, region: GameServerRegion) { - val claim = PendingPlatoonAdmissionStore.claim(token) ?: return + var admitted: PlatoonProfile? = null + val completedFlows = PendingPlatoonAdmissionStore.withClaim(token) { claim -> + admitted = admit(claim, region) + admitted != null + } ?: return + completedFlows.forEach { tokenByFlow.remove(it, token) } + val profile = admitted ?: return + CaptureStatus.report( + CaptureNotice.PLATOON_CONFIRMED, + profile.platoonName.take(MAX_STATUS_NAME_CHARS), + profile.platoonId.toString(), + profile.client.displayName, + ) + } + + /** Persists the claimed identity and replays its payloads; null after reporting a failure. */ + private fun admit(claim: PendingPlatoonAdmissionStore.Claim, region: GameServerRegion): PlatoonProfile? { val ownerPackage = claim.summary.ownerPackage if (region !in clientServerRegions.allowed(ownerPackage)) { - PendingPlatoonAdmissionStore.releaseClaim(token) CaptureStatus.report(CaptureNotice.ADMISSION_REGION_MISMATCH) - return + return null } val client = requireNotNull(PlatoonClient.fromPackage(ownerPackage)) val existing = profileRegistry.findByIdentity( @@ -97,9 +112,8 @@ internal class PlatoonAdmissionCoordinator( claim.summary.profile.platoonId.toLong(), ) if (!admissionGate.canAdmit(ownerPackage, existing != null)) { - PendingPlatoonAdmissionStore.releaseClaim(token) CaptureStatus.report(CaptureNotice.ADMISSION_LIMIT) - return + return null } val previousRegion = clientServerRegions.stored(ownerPackage) val profile = runCatching { @@ -117,13 +131,20 @@ internal class PlatoonAdmissionCoordinator( clientServerRegions.set(ownerPackage, previousRegion) } }.onFailure(error::addSuppressed) - PendingPlatoonAdmissionStore.releaseClaim(token) CaptureStatus.report(CaptureNotice.ADMISSION_PROFILE_FAILED) - return + return null } if (existing == null) admissionGate.markAdmitted(ownerPackage) flows.profileChanged(profile.storageId) + if (!replay(claim, profile)) { + CaptureStatus.report(CaptureNotice.ADMISSION_REPLAY_FAILED) + return null + } + return profile + } + /** Binds live flows to [profile] and routes the buffered payloads in their captured order. */ + private fun replay(claim: PendingPlatoonAdmissionStore.Claim, profile: PlatoonProfile): Boolean { val sessions = mutableMapOf() val replayed = runCatching { claim.flowIds.forEach { flowId -> sessions[flowId] = flows.bind(flowId, profile) } @@ -142,20 +163,12 @@ internal class PlatoonAdmissionCoordinator( } if (replayed.isFailure) { sessions.forEach { (flowId, session) -> flows.unbind(flowId, session) } - PendingPlatoonAdmissionStore.releaseClaim(token) - CaptureStatus.report(CaptureNotice.ADMISSION_REPLAY_FAILED) - return + return false } claim.endedFlowIds.forEach { flowId -> sessions.remove(flowId)?.let { flows.unbind(flowId, it) } } - PendingPlatoonAdmissionStore.complete(token).forEach { tokenByFlow.remove(it, token) } - CaptureStatus.report( - CaptureNotice.PLATOON_CONFIRMED, - profile.platoonName.take(MAX_STATUS_NAME_CHARS), - profile.platoonId.toString(), - profile.client.displayName, - ) + return true } fun discard(token: String) { 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 704abf5..3c01461 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PendingPlatoonAdmissionStoreTest.kt @@ -108,6 +108,34 @@ class PendingPlatoonAdmissionStoreTest { assertFalse(PendingPlatoonAdmissionStore.contains(token)) } + @Test + fun failedOrThrowingAdmissionReleasesTheClaimForAnotherDecision() { + val token = requireNotNull( + PendingPlatoonAdmissionStore.begin( + SupportedGamePackages.HAOPLAY, + profile(5u, "Owls"), + flowId = 4L, + ).token, + ) + + assertNull(PendingPlatoonAdmissionStore.withClaim(token) { false }) + assertNotNull(PendingPlatoonAdmissionStore.summary(token)) + + assertTrue( + runCatching { + PendingPlatoonAdmissionStore.withClaim(token) { error("registry unavailable") } + }.isFailure, + ) + assertNotNull(PendingPlatoonAdmissionStore.summary(token)) + + assertEquals(setOf(4L), PendingPlatoonAdmissionStore.withClaim(token) { claim -> + assertNull(PendingPlatoonAdmissionStore.summary(claim.summary.token)) + true + }) + assertFalse(PendingPlatoonAdmissionStore.contains(token)) + assertNull(PendingPlatoonAdmissionStore.withClaim(token) { true }) + } + @Test fun repeatedIdentitySharesOnePromptWithoutMixingOtherClients() { val first = requireNotNull( From ac6addd11bbcc5e771cefa61ac07c42b58e5e33c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:56:55 +0900 Subject: [PATCH 058/125] refactor: bind only live flows before replaying a confirmed Platoon --- .../dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 a0e915a..7609ad6 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt @@ -147,7 +147,10 @@ internal class PlatoonAdmissionCoordinator( private fun replay(claim: PendingPlatoonAdmissionStore.Claim, profile: PlatoonProfile): Boolean { val sessions = mutableMapOf() val replayed = runCatching { - claim.flowIds.forEach { flowId -> sessions[flowId] = flows.bind(flowId, profile) } + // Live flows stay bound; an ended flow needs a session only while its payloads replay. + claim.flowIds.filterNot(claim.endedFlowIds::contains).forEach { flowId -> + sessions[flowId] = flows.bind(flowId, profile) + } val lastPayloadIndexByFlow = claim.payloads .withIndex() .associate { it.value.flowId to it.index } From 478349f072b9f1f7bdd93908d5d0d87c42e928d6 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:57:14 +0900 Subject: [PATCH 059/125] fix: validate the raw Discord webhook path that is actually sent --- .../main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt | 3 ++- .../java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) 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 2fbf1e3..c6768d0 100644 --- a/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/discord/DiscordWebhookPolicy.kt @@ -16,7 +16,8 @@ object DiscordWebhookPolicy { require(uri.userInfo == null && uri.fragment == null && uri.query == null) { "Discord webhook URL cannot contain credentials, fragments, or query parameters" } - require(uri.path.matches(WEBHOOK_PATH)) { "Invalid Discord incoming-webhook path" } + // The raw path is what is sent, so percent-encoded spellings cannot pass as canonical. + require(uri.rawPath.orEmpty().matches(WEBHOOK_PATH)) { "Invalid Discord incoming-webhook path" } return uri } 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 4b5675c..b1b5eab 100644 --- a/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/discord/DiscordWebhookPolicyTest.kt @@ -19,6 +19,8 @@ class DiscordWebhookPolicyTest { "https://discord.com:444/api/webhooks/12345678901234567/" + token, "https://discord.com/api/webhooks/12345678901234567/" + token + "?wait=true", "https://discord.com/api/users/12345678901234567/" + token, + "https://discord.com/api/webhooks/%3112345678901234567/" + token, + "https://discord.com/api/webhooks/12345678901234567/%61" + token, ).forEach { value -> assertTrue(runCatching { DiscordWebhookPolicy.requireValid(value) }.isFailure) } From 064456da4c9168aadf190ff1d6bd499076141a67 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:57:37 +0900 Subject: [PATCH 060/125] fix: keep the unknown join of an Updates withdrawal period editable --- .../PlatoonDatabaseIntegrationTest.kt | 36 +++++++++++++++++++ .../app/management/MembershipEditing.kt | 8 ++--- .../app/management/MembershipHistory.kt | 20 ++++++++++- .../management/MembershipBoundaryValueTest.kt | 31 ++++++++++++++++ 4 files changed, 90 insertions(+), 5 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 92aab88..a3a1266 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -1315,6 +1315,42 @@ class PlatoonDatabaseIntegrationTest { assertEquals(listOf(override), database.listWeeklyOverrides(periodStart.toEpochDay())) } + @Test + fun unknownJoinOfAnUpdatesWithdrawalPeriodStaysEditable() { + val withdrewAt = Instant.parse("2026-08-10T12:00:00Z") + database.ingestPlatoonUpdates( + listOf(update(PlatoonUpdateSemantics.KIND_WITHDRAW, withdrewAt, TARGET_UID, "Leaver")), + withdrewAt.plusSeconds(60), + ) + val period = database.listMemberStatuses().single { it.uid == TARGET_UID } + .membershipPeriods.single() + assertEquals(null, period.joinedAt) + assertEquals(EvidenceSource.GAME_UPDATES, period.joinedSource) + assertTrue(period.isJoinedBoundaryEditable) + assertFalse(period.isLeftBoundaryEditable) + val joined = MembershipBoundaryValue( + date = LocalDate.of(2026, 8, 1), + instant = Instant.parse("2026-08-01T09:00:00Z"), + timeKnown = true, + ) + val attemptedLeft = MembershipBoundaryValue( + date = LocalDate.of(2026, 8, 20), + instant = Instant.parse("2026-08-20T09:00:00Z"), + timeKnown = true, + ) + + assertTrue(database.updateMembershipPeriod(period.id, joined, attemptedLeft, "Recovered")) + + val updated = database.listMemberStatuses().single { it.uid == TARGET_UID } + .membershipPeriods.single() + assertEquals(joined.instant, updated.joinedAt) + assertEquals(EvidenceSource.MANUAL, updated.joinedSource) + assertEquals(withdrewAt, updated.leftAt) + assertEquals(EvidenceSource.GAME_UPDATES, updated.leftSource) + assertEquals("Recovered", updated.note) + assertForeignKeysValid() + } + private fun update(kind: Long, at: Instant, uid: Long, name: String) = PlatoonUpdateObservation( kind = kind, 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 f348ea2..63c5a89 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -135,12 +135,12 @@ internal object MembershipEditing { val period = readMembershipPeriodForUpdate(db, membershipPeriodId, gameZone) ?: return@inTransaction false // Exact packet-linked boundaries stay immutable; only editable sides take the request. - val effectiveJoined = if (period.joinedSource.isImmutableMembershipBoundary()) { - period.joined + val effectiveJoined = if (isImmutableMembershipBoundary(period.joined, period.joinedSource)) { + requireNotNull(period.joined) } else { joined - } ?: return@inTransaction false - val effectiveLeft = if (period.leftSource?.isImmutableMembershipBoundary() == true) { + } + val effectiveLeft = if (isImmutableMembershipBoundary(period.left, period.leftSource)) { period.left } else { left 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 b761f0e..54705b0 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipHistory.kt @@ -30,11 +30,29 @@ fun isValidMembershipRange( fun EvidenceSource.isImmutableMembershipBoundary(): Boolean = this == EvidenceSource.GAME_UPDATES +/** + * 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 + * editable. + */ +internal fun isImmutableMembershipBoundary( + value: MembershipBoundaryValue?, + source: EvidenceSource?, +): Boolean = value != null && source?.isImmutableMembershipBoundary() == true + +/** Whether the join side may be edited; see [isImmutableMembershipBoundary]. */ +val MembershipPeriod.isJoinedBoundaryEditable: Boolean + get() = joinedAt == null || !joinedSource.isImmutableMembershipBoundary() + +/** Whether the withdrawal side may be edited; see [isImmutableMembershipBoundary]. */ +val MembershipPeriod.isLeftBoundaryEditable: Boolean + get() = leftAt == null || leftSource?.isImmutableMembershipBoundary() != true + internal fun editableMembershipBoundaryChanged( original: MembershipBoundaryValue?, requested: MembershipBoundaryValue?, source: EvidenceSource?, -): Boolean = source?.isImmutableMembershipBoundary() != true && original != requested +): Boolean = !isImmutableMembershipBoundary(original, source) && original != requested internal fun migrationCalendarDate(epochMillis: Long, zoneId: ZoneId): LocalDate = Instant.ofEpochMilli(epochMillis).atZone(zoneId).toLocalDate() diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryValueTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryValueTest.kt index d0e4e1b..090faf5 100644 --- a/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryValueTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/MembershipBoundaryValueTest.kt @@ -51,6 +51,37 @@ class MembershipBoundaryValueTest { ) } + @Test + fun `an absent Updates boundary stays editable`() { + val requested = MembershipBoundaryValue( + date = LocalDate.of(2026, 7, 29), + instant = Instant.parse("2026-07-29T10:15:00Z"), + timeKnown = true, + ) + val period = MembershipPeriod( + id = 1, + uid = 7, + joinedAt = null, + leftAt = Instant.parse("2026-07-30T00:00:00Z"), + joinedDate = null, + leftDate = null, + joinedTimeKnown = true, + leftTimeKnown = true, + joinedPrecision = EvidencePrecision.UNKNOWN, + leftPrecision = EvidencePrecision.EXACT, + joinedSource = EvidenceSource.GAME_UPDATES, + leftSource = EvidenceSource.GAME_UPDATES, + note = "", + ) + + assertTrue(editableMembershipBoundaryChanged(null, requested, EvidenceSource.GAME_UPDATES)) + assertFalse(isImmutableMembershipBoundary(null, EvidenceSource.GAME_UPDATES)) + assertTrue(isImmutableMembershipBoundary(requested, EvidenceSource.GAME_UPDATES)) + assertTrue(period.isJoinedBoundaryEditable) + assertFalse(period.isLeftBoundaryEditable) + assertFalse(period.copy(joinedAt = requested.instant).isJoinedBoundaryEditable) + } + @Test fun allowsSameDateWhenEitherManualTimeIsUnknown() { val joined = boundary("2026-07-29", "2026-07-29T12:00:00Z", timeKnown = true) From 5ed4385b6dfb593b69861e1f5d241e3f6df874db Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:58:15 +0900 Subject: [PATCH 061/125] refactor: drop unused capture status and checklist helpers --- .../dev/gf2log/app/capture/CaptureFlowEngine.kt | 1 + .../java/dev/gf2log/app/capture/CaptureStatus.kt | 5 ----- .../gf2log/app/capture/ScopedCaptureChecklist.kt | 13 ++----------- .../dev/gf2log/app/capture/CaptureStatusTest.kt | 4 +--- .../app/capture/ScopedCaptureChecklistTest.kt | 12 ------------ 5 files changed, 4 insertions(+), 31 deletions(-) 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 760f763..ac1c5ce 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt @@ -198,6 +198,7 @@ internal class CaptureFlowEngine( } private fun process(flowId: Long, events: List, flowEnded: Boolean = false) { + if (events.isEmpty()) return val payloads = mutableListOf() var warnings = 0 events.forEach { event -> 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 2c6c24b..25f5227 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt @@ -94,15 +94,11 @@ object CaptureStatus { private val listeners = CopyOnWriteArraySet<(CaptureSnapshot) -> Unit>() private val dispatchPending = AtomicBoolean(false) - fun snapshot(): CaptureSnapshot = state.get() val isRunning: Boolean get() = snapshot().phase == CapturePhase.RUNNING - val isStarting: Boolean - get() = snapshot().phase == CapturePhase.STARTING - /** [listener] runs on the main looper: once immediately, then after every change. */ fun addListener(listener: (CaptureSnapshot) -> Unit) { listeners += listener @@ -171,7 +167,6 @@ object CaptureStatus { private fun event(notice: CaptureNotice, args: Array = emptyArray()) = CaptureNoticeEvent(notice, args.toList(), System.currentTimeMillis()) - data class GuidedProgress( val capturedPayloadTypes: Set = emptySet(), ) diff --git a/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt b/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt index d3b6331..f0da03e 100644 --- a/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt +++ b/app/src/main/java/dev/gf2log/app/capture/ScopedCaptureChecklist.kt @@ -8,14 +8,10 @@ internal class ScopedCaptureChecklist(private val requiredTypes: Set) { private val capturedByScope = ConcurrentHashMap>() private val targetScope = AtomicReference(null) - fun mark(scopeId: String, payloadType: Int, chooseTarget: Boolean): Set { + fun mark(scopeId: String, payloadType: Int, chooseTarget: Boolean) { require(scopeId.isNotBlank()) if (chooseTarget) targetScope.compareAndSet(null, scopeId) - val captured = capturedByScope.computeIfAbsent(scopeId) { - ConcurrentHashMap.newKeySet() - } - captured += payloadType - return captured.toSet() + capturedByScope.computeIfAbsent(scopeId) { ConcurrentHashMap.newKeySet() } += payloadType } fun targetScopeId(): String? = targetScope.get() @@ -26,9 +22,4 @@ internal class ScopedCaptureChecklist(private val requiredTypes: Set) { .orEmpty() fun targetComplete(): Boolean = targetCaptured().containsAll(requiredTypes) - - fun clear() { - capturedByScope.clear() - targetScope.set(null) - } } 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 50d1584..12e4ca8 100644 --- a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt @@ -3,7 +3,6 @@ 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 @@ -31,14 +30,13 @@ class CaptureStatusTest { assertNull(CaptureStatus.snapshot().notice) CaptureStatus.markStarting() - assertTrue(CaptureStatus.isStarting) + assertEquals(CapturePhase.STARTING, CaptureStatus.snapshot().phase) assertEquals(CaptureNotice.PREPARING, CaptureStatus.snapshot().notice?.notice) CaptureStatus.markRunning(listOf("pkg.one", "pkg.two")) CaptureStatus.report(CaptureNotice.ROSTER_SAVED, "12", "Owls") val running = CaptureStatus.snapshot() assertTrue(CaptureStatus.isRunning) - assertFalse(CaptureStatus.isStarting) assertEquals(listOf("pkg.one", "pkg.two"), running.capturedTargets) assertEquals(CaptureNotice.ROSTER_SAVED, running.notice?.notice) assertEquals(listOf("12", "Owls"), running.notice?.args) diff --git a/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt b/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt index 35c4e38..4714bb0 100644 --- a/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/ScopedCaptureChecklistTest.kt @@ -23,16 +23,4 @@ class ScopedCaptureChecklistTest { assertTrue(checklist.targetComplete()) } - - @Test - fun clearRemovesTargetAndCapturedEvidence() { - val checklist = ScopedCaptureChecklist(setOf(1)) - checklist.mark("profile", 1, chooseTarget = true) - - checklist.clear() - - assertEquals(null, checklist.targetScopeId()) - assertTrue(checklist.targetCaptured().isEmpty()) - assertFalse(checklist.targetComplete()) - } } From f49077bfeb6884963897976200d31b784310b6e7 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:58:31 +0900 Subject: [PATCH 062/125] fix: tighten contrast, guard repeat actions and keep unsaved edits - Raise secondary text and status mark contrast; accent tints use the accessible accent ink - Scale weekly grid rows and columns with the system font size - Admission prompt reports failures in place and offers the next pending Platoon - Block double submission for send, share, backup, restore and webhook save - Dismiss an Activity's dialogs on destroy; ask before leaving an edited member - Home history selection survives rotation and updates rows in place - Plain-language wording pass for English and Korean --- .../main/java/dev/gf2log/app/BaseActivity.kt | 5 ++ .../main/java/dev/gf2log/app/CsvImportFlow.kt | 9 +- .../main/java/dev/gf2log/app/MainActivity.kt | 86 +++++++++++-------- .../dev/gf2log/app/MemberDetailActivity.kt | 23 ++++- .../java/dev/gf2log/app/OptionsActivity.kt | 17 +++- .../dev/gf2log/app/PacketHistoryActivity.kt | 6 +- .../app/PendingPlatoonAdmissionPrompt.kt | 33 +++++++ .../dev/gf2log/app/PlatoonProfileSelector.kt | 5 +- .../main/java/dev/gf2log/app/WeeklyDialogs.kt | 4 +- .../dev/gf2log/app/WeeklyReportActivity.kt | 6 +- .../main/java/dev/gf2log/app/WeeklyTable.kt | 30 +++++-- .../main/java/dev/gf2log/app/ui/Controls.kt | 4 +- .../main/java/dev/gf2log/app/ui/Dialogs.kt | 16 ++++ app/src/main/java/dev/gf2log/app/ui/Labels.kt | 4 + app/src/main/res/drawable/bg_button.xml | 29 ------- app/src/main/res/drawable/bg_edit_text.xml | 7 +- app/src/main/res/drawable/ic_chevron_next.xml | 14 --- app/src/main/res/drawable/ic_info_outline.xml | 2 +- app/src/main/res/values-ko/strings.xml | 29 ++++--- app/src/main/res/values-night/colors.xml | 1 - app/src/main/res/values-night/styles.xml | 13 ++- app/src/main/res/values/colors.xml | 11 ++- app/src/main/res/values/strings.xml | 33 +++---- app/src/main/res/values/styles.xml | 28 ++---- 24 files changed, 246 insertions(+), 169 deletions(-) delete mode 100644 app/src/main/res/drawable/bg_button.xml delete mode 100644 app/src/main/res/drawable/ic_chevron_next.xml diff --git a/app/src/main/java/dev/gf2log/app/BaseActivity.kt b/app/src/main/java/dev/gf2log/app/BaseActivity.kt index 40fd6f0..195ab9d 100644 --- a/app/src/main/java/dev/gf2log/app/BaseActivity.kt +++ b/app/src/main/java/dev/gf2log/app/BaseActivity.kt @@ -61,6 +61,11 @@ abstract class BaseActivity : ComponentActivity() { super.onStop() } + override fun onDestroy() { + dev.gf2log.app.ui.Dialogs.dismissAll(this) + super.onDestroy() + } + final override fun onResume() { super.onResume() if ( diff --git a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt index b2a17bc..8d01b3d 100644 --- a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt +++ b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt @@ -14,6 +14,7 @@ import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.captureActive import dev.gf2log.app.ui.DocumentAccess import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels @@ -71,7 +72,7 @@ internal class CsvImportFlow( } fun start() { - if (CaptureStatus.isRunning) { + if (captureActive) { Feedback.show(activity, R.string.stop_capture_before_csv_import, long = true) return } @@ -79,7 +80,7 @@ internal class CsvImportFlow( } fun undo() { - if (CaptureStatus.isRunning) { + if (captureActive) { Feedback.show(activity, R.string.stop_capture_before_csv_import, long = true) return } @@ -248,14 +249,14 @@ internal class CsvImportFlow( private fun apply(import: PendingImport) { val target = resolveTarget(import.target.scope.storageId) - if (pending !== import || CaptureStatus.isRunning || target == null || + if (pending !== import || captureActive || target == null || target.profile.client != import.target.profile.client || target.profile.platoonId != import.target.profile.platoonId ) { clear() Feedback.show( activity, - if (CaptureStatus.isRunning) R.string.stop_capture_before_csv_import else R.string.csv_import_target_changed, + if (captureActive) R.string.stop_capture_before_csv_import else R.string.csv_import_target_changed, long = true, ) return diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index b79105f..a4e18b0 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -143,8 +143,11 @@ class MainActivity : BaseActivity() { captureOnceRequested = it.getBoolean(STATE_CAPTURE_ONCE) showingSaved = it.getBoolean(STATE_SHOWING_SAVED) showAll = it.getBoolean(STATE_SHOW_ALL) + selecting = it.getBoolean(STATE_SELECTING) + it.getStringArrayList(STATE_SELECTED)?.let(selectedIds::addAll) } onBackPressedDispatcher.addCallback(this, exitSelection) + exitSelection.isEnabled = selecting screen = Screen(this, PrimaryNavigation.Destination.HOME) screen.topBar .setLeading(ImageView(this).apply { @@ -184,6 +187,8 @@ class MainActivity : BaseActivity() { outState.putBoolean(STATE_CAPTURE_ONCE, captureOnceRequested) outState.putBoolean(STATE_SHOWING_SAVED, showingSaved) outState.putBoolean(STATE_SHOW_ALL, showAll) + outState.putBoolean(STATE_SELECTING, selecting) + outState.putStringArrayList(STATE_SELECTED, ArrayList(selectedIds)) super.onSaveInstanceState(outState) } @@ -575,17 +580,18 @@ class MainActivity : BaseActivity() { } private fun historyRow(entry: CaptureHistoryStore.Entry): View { - val isSelected = entry.id in selectedIds val tag = Labels.payloadTag(this, entry.payloadType) - return AccessibleRow(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - minimumHeight = dp(52) - setPadding(dp(12), dp(4), dp(8), dp(4)) - background = selectableRect() - this.isSelected = selecting && isSelected - contentDescription = listOfNotNull( - getString(R.string.selected).takeIf { selecting && isSelected }, + val mark = icon(R.drawable.ic_circle_outline, R.color.outline_strong, sizeDp = 22) + val row = AccessibleRow(this) + fun refresh() { + val isSelected = selecting && entry.id in selectedIds + row.isSelected = isSelected + mark.setImageResource(if (isSelected) R.drawable.ic_check_circle else R.drawable.ic_circle_outline) + mark.imageTintList = android.content.res.ColorStateList.valueOf( + getColor(if (isSelected) R.color.accent_text else R.color.outline_strong), + ) + row.contentDescription = listOfNotNull( + getString(R.string.selected).takeIf { isSelected }, entry.title, getString( R.string.payload_tag_description, @@ -593,56 +599,64 @@ class MainActivity : BaseActivity() { entry.payloadType?.toString() ?: getString(R.string.unknown_payload_type), ), ).joinToString(". ") - if (selecting) { - addView(icon( - if (isSelected) R.drawable.ic_check_circle else R.drawable.ic_circle_outline, - if (isSelected) R.color.accent else R.color.outline_strong, - sizeDp = 22, - ), lp(22, 22, end = 12)) - } + } + return row.apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(52) + setPadding(dp(12), dp(4), dp(8), dp(4)) + background = selectableRect() + if (selecting) addView(mark, lp(22, 22, end = 12)) addView(text(entry.title, TextRole.BODY) { typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) }, lp(width = 0, weight = 1f)) - addView(text(tag, TextRole.LABEL) { - textSize = 11f - setTextColor(getColor(R.color.accent_text)) - setPadding(dp(8), dp(3), dp(8), dp(3)) - background = GradientDrawable().apply { - setColor(getColor(R.color.accent_surface)) - cornerRadius = dpf(8f) - } - }, lp(WRAP, WRAP, start = 8)) + addView(badge(tag).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(WRAP, WRAP, start = 8)) if (!selecting) addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) + refresh() setOnClickListener { if (selecting) { + // Update in place so accessibility focus stays on this row. if (!selectedIds.add(entry.id)) selectedIds.remove(entry.id) - renderHistory() + refresh() + updateSelectionBar() } else { openEntry(entry) } } setOnLongClickListener { - if (!selecting) setSelecting(true) selectedIds.add(entry.id) - renderHistory() + if (selecting) { + refresh() + updateSelectionBar() + } else { + setSelecting(true) + } true } } } + private var selectionCount: TextView? = null + private val selectionButtons = mutableListOf() + private fun selectionActions(): View = row { - addView( - text(resources.getQuantityString(R.plurals.selected_count, selectedIds.size, selectedIds.size), TextRole.LABEL), - lp(width = 0, weight = 1f, start = 4), - ) + selectionButtons.clear() + selectionCount = text(null, TextRole.LABEL) + addView(selectionCount, lp(width = 0, weight = 1f, start = 4)) if (!showingSaved) { addView(button(getString(R.string.save_selected_history_short), Controls.Role.SECONDARY, R.drawable.ic_save) { saveSelected() - }.apply { isEnabled = selectedIds.isNotEmpty() }, lp(WRAP, WRAP, end = 8)) + }.also(selectionButtons::add), lp(WRAP, WRAP, end = 8)) } addView(button(getString(R.string.delete), Controls.Role.DESTRUCTIVE_TEXT, R.drawable.ic_delete) { confirmDeleteSelected() - }.apply { isEnabled = selectedIds.isNotEmpty() }, lp(WRAP, WRAP)) + }.also(selectionButtons::add), lp(WRAP, WRAP)) + updateSelectionBar() + } + + private fun updateSelectionBar() { + selectionCount?.text = resources.getQuantityString(R.plurals.selected_count, selectedIds.size, selectedIds.size) + selectionButtons.forEach { it.isEnabled = selectedIds.isNotEmpty() } } private fun setSelecting(enabled: Boolean) { @@ -726,5 +740,7 @@ class MainActivity : BaseActivity() { const val STATE_CAPTURE_ONCE = "home.capture_once" const val STATE_SHOWING_SAVED = "home.showing_saved" const val STATE_SHOW_ALL = "home.show_all" + const val STATE_SELECTING = "home.selecting" + const val STATE_SELECTED = "home.selected" } } diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 14c455c..7c357d2 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -73,8 +73,11 @@ class MemberDetailActivity : BaseActivity() { repository = PlatoonRepository(this, scope) inputsInitialized = savedInstanceState?.getBoolean(STATE_INPUTS_INITIALIZED) == true screen = Screen(this) + onBackPressedDispatcher.addCallback(this, object : androidx.activity.OnBackPressedCallback(true) { + override fun handleOnBackPressed() = leave() + }) screen.topBar - .setNavigation(::finish) + .setNavigation(::leave) .setTitle(getString(R.string.member_details)) .setActions( emptyList(), @@ -223,6 +226,24 @@ class MemberDetailActivity : BaseActivity() { updateSaveState() } + /** Unsaved nickname or note edits are confirmed instead of silently discarded. */ + private fun leave() { + if (!::saveButton.isInitialized || !saveButton.isEnabled) { + finish() + return + } + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.unsaved_changes_title) + .setMessage(R.string.unsaved_changes_message) + .setNegativeButton(R.string.discard) { _, _ -> finish() } + .setPositiveButton(R.string.save_member) { _, _ -> + saveProfile() + finish() + }, + ) + } + private fun updateSaveState() { val loaded = model?.status saveButton.isEnabled = loaded != null && !work.isBusy(SAVE) && diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 2e4ec78..7651d71 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -29,6 +29,7 @@ import dev.gf2log.app.settings.PayloadHistoryPreferences import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.captureActive import dev.gf2log.app.ui.DocumentAccess import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels @@ -265,8 +266,10 @@ class OptionsActivity : BaseActivity() { } private fun updateResetRegion(scope: PlatoonStorageScope, region: GameServerRegion?) { + if (work.isBusy(MAINTENANCE)) return Feedback.show(this, R.string.rebuilding_weekly_history) work.run( + key = MAINTENANCE, task = { val previousAutomatic = GameTimeZonePreferences.isAutomatic(this, scope.storageId) val previousRegion = GameTimeZonePreferences.region(this, scope.storageId) @@ -314,7 +317,7 @@ class OptionsActivity : BaseActivity() { // region Backup private fun startExport(complete: Boolean) { - if (CaptureStatus.isRunning) { + if (captureActive) { Feedback.show(this, R.string.stop_capture_before_backup, long = true) return } @@ -324,7 +327,7 @@ class OptionsActivity : BaseActivity() { } private fun confirmRestore() { - if (CaptureStatus.isRunning) { + if (captureActive) { Feedback.show(this, R.string.stop_capture_before_backup, long = true) return } @@ -338,8 +341,10 @@ class OptionsActivity : BaseActivity() { Feedback.show(this, R.string.invalid_full_backup, long = true) return } + if (work.isBusy(MAINTENANCE)) return Feedback.show(this, R.string.backup_restoring) work.run( + key = MAINTENANCE, task = { DocumentAccess.openInput(contentResolver, uri).use { input -> PlatoonBackupManager.restoreSelected(this, input) @@ -366,8 +371,9 @@ class OptionsActivity : BaseActivity() { failure: (Throwable) -> Int, operation: () -> Unit, ) { + if (work.isBusy(MAINTENANCE)) return Feedback.show(this, progress) - work.run(task = operation) { result -> + work.run(key = MAINTENANCE, task = operation) { result -> Feedback.show(this, result.fold(onSuccess = { success }, onFailure = failure), long = true) } } @@ -415,7 +421,8 @@ class OptionsActivity : BaseActivity() { Dialogs.show(builder) { dialog -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { val value = input.text.toString() - work.run(task = { DiscordWebhookSecretStore(this).save(value) }) { result -> + if (work.isBusy(WEBHOOK)) return@setOnClickListener + work.run(key = WEBHOOK, task = { DiscordWebhookSecretStore(this).save(value) }) { result -> if (result.isSuccess) { dialog.dismiss() Feedback.show(this, R.string.discord_webhook_saved) @@ -487,6 +494,8 @@ class OptionsActivity : BaseActivity() { private companion object { const val STATE_EXPORT_COMPLETE = "settings.export_complete" + const val MAINTENANCE = "maintenance" + const val WEBHOOK = "webhook" } } diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index ca854d4..eddd79b 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -180,9 +180,10 @@ class PacketHistoryActivity : BaseActivity() { ) return@run } - Dialogs.confirm(this, R.string.send_original_csv, R.string.send_original_csv_warning, R.string.send) { + Dialogs.confirm(this, R.string.send_original_csv, R.string.send_original_csv_warning, R.string.send) confirm@{ + if (work.isBusy(SEND)) return@confirm Feedback.show(this, R.string.discord_csv_sending) - work.run(task = { DiscordWebhookSender().send(webhook, csv) }) { sent -> + work.run(key = SEND, task = { DiscordWebhookSender().send(webhook, csv) }) { sent -> Feedback.show(this, if (sent.isSuccess) R.string.discord_csv_sent else R.string.discord_csv_send_failed, long = true) } } @@ -195,6 +196,7 @@ class PacketHistoryActivity : BaseActivity() { const val EXTRA_SAVED_ENTRY = "saved_entry" const val EXTRA_STORAGE_ID = "storage_id" private const val TABLE = "table" + private const val SEND = "send" private const val RAW = "raw" private const val STATE_RAW = "packet.raw" } diff --git a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt index 1b2433c..b223d45 100644 --- a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt @@ -7,7 +7,11 @@ import android.text.TextUtils import android.view.View import android.widget.RadioButton import android.widget.RadioGroup +import dev.gf2log.app.capture.CaptureNotice +import dev.gf2log.app.capture.CaptureSnapshot +import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.capture.CaptureVpnService +import dev.gf2log.app.capture.format import dev.gf2log.app.capture.PendingPlatoonAdmissionStore import dev.gf2log.app.management.PlatoonClient import dev.gf2log.app.settings.ClientServerRegionPreferences @@ -63,6 +67,10 @@ internal object PendingPlatoonAdmissionPrompt { val client = PlatoonClient.fromPackage(candidate.ownerPackage) ?: return val regions = ClientServerRegionPreferences.allowedFor(candidate.ownerPackage) val radios = RadioGroup(activity).apply { orientation = RadioGroup.VERTICAL } + val status = activity.text(null, TextRole.CAPTION) { + visibility = View.GONE + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + } val content = Dialogs.content(activity) { addView(activity.text(activity.getString(R.string.new_platoon_detected_message), TextRole.SECONDARY), activity.lp()) addView(activity.panel(padding = 12) { @@ -77,8 +85,10 @@ internal object PendingPlatoonAdmissionPrompt { }, activity.lp(top = 12)) addView(activity.text(activity.getString(R.string.choose_server_region), TextRole.SUBTITLE), activity.lp(top = 16)) addView(radios, activity.lp(top = 4)) + addView(status, activity.lp(top = 8)) addView(activity.text(activity.getString(R.string.new_platoon_memory_only_notice), TextRole.CAPTION), activity.lp(top = 8)) } + var statusListener: ((CaptureSnapshot) -> Unit)? = null val dialog = Dialogs.show( Dialogs.builder(activity) .setTitle(R.string.new_platoon_detected) @@ -106,6 +116,20 @@ internal object PendingPlatoonAdmissionPrompt { } confirm.setOnClickListener { val region = selections[candidate.token] ?: return@setOnClickListener + val requestedAt = System.currentTimeMillis() + status.text = activity.getString(R.string.confirming_platoon) + status.setTextColor(activity.getColor(R.color.text_secondary)) + status.visibility = View.VISIBLE + // Failures arrive only as capture notices; surface them here and allow a retry. + statusListener?.let(CaptureStatus::removeListener) + statusListener = { snapshot: CaptureSnapshot -> + val event = snapshot.notice + if (event != null && event.atMillis >= requestedAt && event.notice in ADMISSION_FAILURES) { + status.text = event.format(activity) + status.setTextColor(activity.getColor(R.color.destructive_action)) + confirm.isEnabled = true + } + }.also(CaptureStatus::addListener) activity.startService( Intent(activity, CaptureVpnService::class.java) .setAction(CaptureVpnService.ACTION_CONFIRM_PENDING_PLATOON) @@ -117,6 +141,7 @@ internal object PendingPlatoonAdmissionPrompt { shown.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { confirmDiscard(activity, candidate) } } dialog.setOnDismissListener { + statusListener?.let(CaptureStatus::removeListener) if (visible.get() === dialog) clearVisible() } visible = WeakReference(dialog) @@ -140,4 +165,12 @@ internal object PendingPlatoonAdmissionPrompt { destructive = true, ) } + + private val ADMISSION_FAILURES = setOf( + CaptureNotice.ADMISSION_INVALID, + CaptureNotice.ADMISSION_REGION_MISMATCH, + CaptureNotice.ADMISSION_LIMIT, + CaptureNotice.ADMISSION_PROFILE_FAILED, + CaptureNotice.ADMISSION_REPLAY_FAILED, + ) } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index 33e1f5b..325848c 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -18,6 +18,7 @@ import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.ui.AccessibleRow import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.captureActive import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.TextRole @@ -206,7 +207,7 @@ internal object PlatoonProfileSelector { } private fun chooseProfileRegion(activity: Activity, profile: PlatoonProfile) { - if (CaptureStatus.isRunning) { + if (captureActive) { Feedback.show(activity, R.string.stop_capture_before_profile_change, long = true) return } @@ -231,7 +232,7 @@ internal object PlatoonProfileSelector { } private fun confirmDeleteFirst(activity: Activity, profile: PlatoonProfile) { - if (CaptureStatus.isRunning) { + if (captureActive) { Feedback.show(activity, R.string.stop_capture_before_profile_change, long = true) return } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt index 53eb220..8a98cae 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -192,8 +192,8 @@ internal object WeeklyDialogs { fun showGuide(activity: Activity, isGunsmokeWeek: Boolean) { val rows = listOf( - "✓" to activity.getString(R.string.evidence_guide_exact), - "≥" to activity.getString(R.string.evidence_guide_minimum), + "90" to activity.getString(R.string.evidence_guide_exact), + "≥90" to activity.getString(R.string.evidence_guide_minimum), "?" to activity.getString(R.string.evidence_guide_unknown), activity.getString(R.string.login_initial) + " / " + activity.getString(R.string.patrol_initial) to activity.getString(R.string.evidence_guide_marks), diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 52bd62f..d0dbaf1 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -411,7 +411,7 @@ class WeeklyReportActivity : BaseActivity() { }, lp()) contentDescription = "$weekType. $range. ${getString(R.string.select_week)}" }, lp(width = 0, weight = 1f)) - addView(iconButton(R.drawable.ic_chevron_next, getString(R.string.next_week)) { + addView(iconButton(R.drawable.ic_chevron_right, getString(R.string.next_week)) { reportState.showNextWeek(report.periodEnd) requestRender() }.apply { isEnabled = !editing }, lp(48, 48)) @@ -782,9 +782,11 @@ class WeeklyReportActivity : BaseActivity() { private fun share() { val current = model ?: return + if (work.isBusy(PNG)) return WeeklyDialogs.showShare(this) { privacy, shareAfter -> Feedback.show(this, R.string.weekly_png_rendering) work.run( + key = PNG, task = { WeeklyPngExport.render( this, @@ -815,6 +817,7 @@ class WeeklyReportActivity : BaseActivity() { private fun addNote(report: WeeklyReportBuilder.Report) { val text = noteInput.text.toString().trim() val day = noteDay ?: return + if (work.isBusy(SAVE)) return if (text.isEmpty()) { noteInput.error = getString(R.string.note_required) return @@ -918,6 +921,7 @@ class WeeklyReportActivity : BaseActivity() { const val STANDARD_ROW = 0 const val GUNSMOKE_ROW = 1 const val SAVE = "save" + const val PNG = "png" const val EXPORT_ALL = "all" const val MAX_NOTE_LENGTH = 1_000 const val STATE_REFERENCE_DAY = "weekly.reference_day" diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt index cc32729..f21969d 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt @@ -104,7 +104,12 @@ internal class WeeklyTable( ) { /** Reference instant for open-day marks; refreshed with each render. */ var now: Instant = Instant.now() - val rowHeight = context.dp(if (isGunsmokeWeek) GUNSMOKE_ROW_HEIGHT else STANDARD_ROW_HEIGHT) + /** Heights follow the user's font size; widths grow more gently so a week still fits. */ + private val fontScale = context.resources.configuration.fontScale.coerceAtLeast(1f) + private fun height(dp: Int) = (context.dp(dp) * fontScale).toInt() + private fun width(dp: Int) = (context.dp(dp) * fontScale.coerceAtMost(1.5f)).toInt() + + val rowHeight = height(if (isGunsmokeWeek) GUNSMOKE_ROW_HEIGHT else STANDARD_ROW_HEIGHT) fun headerRow(report: WeeklyReportBuilder.Report): View = gridRow( frozen = headerCell(context.getString(R.string.member), null, MEMBER_WIDTH, Gravity.START or Gravity.CENTER_VERTICAL), @@ -117,7 +122,7 @@ internal class WeeklyTable( today = day == PlatoonPeriods.gameDay(now, zone), ) } + headerCell(context.getString(R.string.total), null, TOTAL_WIDTH, Gravity.CENTER), - height = context.dp(HEADER_HEIGHT), + height = height(HEADER_HEIGHT), ) /** One reusable member row; [bind] refreshes it in place for RecyclerView recycling. */ @@ -200,14 +205,23 @@ internal class WeeklyTable( addView(merit, context.lp()) addView(gunsmoke, context.lp()) addView(activity, context.lp()) - layoutParams = LinearLayout.LayoutParams(context.dp(widthDp), rowHeight) + layoutParams = LinearLayout.LayoutParams(width(widthDp), rowHeight) isClickable = true isFocusable = true } fun bind(display: CellDisplay, description: String, onClick: () -> Unit) { merit.text = display.merit - merit.setTextColor(context.getColor(if (display.meritWarn) R.color.cutline_mark else R.color.text_primary)) + // Unknown values recede so confirmed numbers and cutline warnings stand out. + merit.setTextColor( + context.getColor( + when { + display.meritWarn -> R.color.cutline_mark + display.merit == "?" -> R.color.text_secondary + else -> R.color.text_primary + }, + ), + ) 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) { @@ -360,7 +374,7 @@ internal class WeeklyTable( }, context.lp()) } if (today) contentDescription = context.getString(R.string.today_column, title, subtitle.orEmpty()) - layoutParams = LinearLayout.LayoutParams(context.dp(widthDp), LinearLayout.LayoutParams.MATCH_PARENT) + layoutParams = LinearLayout.LayoutParams(width(widthDp), LinearLayout.LayoutParams.MATCH_PARENT) } /** Grid row: hairline separators come from a 1px gap over the outline-colored row. */ @@ -370,15 +384,15 @@ internal class WeeklyTable( addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL cells.forEach { cell -> - val width = (cell.layoutParams as? LinearLayout.LayoutParams)?.width ?: context.dp(DAY_WIDTH) - addView(cell, LinearLayout.LayoutParams(width, height - hairline).apply { marginStart = hairline }) + val cellWidth = (cell.layoutParams as? LinearLayout.LayoutParams)?.width ?: width(DAY_WIDTH) + addView(cell, LinearLayout.LayoutParams(cellWidth, height - hairline).apply { marginStart = hairline }) } }) } return LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL setBackgroundColor(context.getColor(R.color.outline)) - addView(frozen, LinearLayout.LayoutParams(context.dp(MEMBER_WIDTH), height - hairline)) + addView(frozen, LinearLayout.LayoutParams(width(MEMBER_WIDTH), height - hairline)) addView(scroller, LinearLayout.LayoutParams(0, height, 1f)) layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, height) } diff --git a/app/src/main/java/dev/gf2log/app/ui/Controls.kt b/app/src/main/java/dev/gf2log/app/ui/Controls.kt index d0f7525..7ef059c 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Controls.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Controls.kt @@ -118,7 +118,7 @@ internal object Controls { ), intArrayOf( context.getColor(R.color.outline_strong), - context.getColor(R.color.accent), + context.getColor(R.color.accent_text), context.getColor(R.color.outline_strong), ), ) @@ -131,7 +131,7 @@ internal object Controls { ), intArrayOf( context.getColor(R.color.surface_variant), - context.getColor(R.color.accent), + context.getColor(R.color.accent_text), context.getColor(R.color.surface), ), ) 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 730bb65..034bf51 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt @@ -35,9 +35,25 @@ internal object Dialogs { onShown(dialog) } dialog.show() + owner(builder.context)?.let { owner -> + open.getOrPut(owner) { mutableListOf() }.add(java.lang.ref.WeakReference(dialog)) + } return dialog } + private val open = java.util.WeakHashMap>>() + + private tailrec fun owner(context: Context): android.app.Activity? = when (context) { + is android.app.Activity -> context + is android.content.ContextWrapper -> owner(context.baseContext) + else -> null + } + + /** Closes every dialog a screen opened so a recreated screen never leaks its windows. */ + fun dismissAll(owner: android.app.Activity) { + open.remove(owner)?.forEach { it.get()?.takeIf(AlertDialog::isShowing)?.dismiss() } + } + fun confirm( context: Context, title: CharSequence, 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 e861645..e7aac12 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Labels.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Labels.kt @@ -101,3 +101,7 @@ internal object Labels { }, ) } + +/** True from the moment capture is requested until it fully stops; risky data operations wait. */ +internal val captureActive: Boolean + get() = dev.gf2log.app.capture.CaptureStatus.snapshot().phase != dev.gf2log.app.capture.CapturePhase.STOPPED diff --git a/app/src/main/res/drawable/bg_button.xml b/app/src/main/res/drawable/bg_button.xml deleted file mode 100644 index d6c6066..0000000 --- a/app/src/main/res/drawable/bg_button.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/bg_edit_text.xml b/app/src/main/res/drawable/bg_edit_text.xml index 8d3b534..f7a9011 100644 --- a/app/src/main/res/drawable/bg_edit_text.xml +++ b/app/src/main/res/drawable/bg_edit_text.xml @@ -3,15 +3,14 @@ - - + + - - + diff --git a/app/src/main/res/drawable/ic_chevron_next.xml b/app/src/main/res/drawable/ic_chevron_next.xml deleted file mode 100644 index 64b71dd..0000000 --- a/app/src/main/res/drawable/ic_chevron_next.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_info_outline.xml b/app/src/main/res/drawable/ic_info_outline.xml index c61ca2c..517eac4 100644 --- a/app/src/main/res/drawable/ic_info_outline.xml +++ b/app/src/main/res/drawable/ic_info_outline.xml @@ -7,7 +7,7 @@ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 365454e..e129a76 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -45,7 +45,7 @@ 현재 가입 중이거나 과거에 탈퇴한 멤버의 정보와 가입·탈퇴 이력을 검색하고 관리합니다. CSV를 적용하기 전에 변경 내용을 미리 보고, 자동 체크포인트로 최근 가져오기를 되돌릴 수 있습니다. 분리된 서클을 전환하고, 스냅샷을 비교하거나 멤버를 길게 눌러 선택해 내보낼 수 있습니다. - 주간 테이블 + 주간 과업표 일반 주간과 흙먼지 주간이 구분된 주간 공적표를 확인합니다. 각 셀을 눌러 해당 값의 근거와 사용된 패킷 정보를 확인할 수 있습니다. 연필 버튼으로 공적이나 흙먼지 값을 수정하고, 공유 버튼으로 주간 표를 저장하거나 공유합니다. @@ -72,7 +72,7 @@ 먼저 멤버를 하나 이상 선택하세요 선택 멤버를 내보냈습니다 이름 또는 UID 검색 - 가입 + 가입 중 이름 이번 주 공적 누적 공적 @@ -100,10 +100,10 @@ 가입 이력 추가 가입 이력 추가 멤버 삭제 - 멤버를 삭제하면 저장된 가입 이력, 비고와 주간 과업표 수정사항이 영구적으로 삭제됩니다. 이후 새 명단 패킷에서 멤버가 다시 추가될 수 있지만 삭제된 정보는 복구되지 않습니다. + 멤버를 삭제하면 가입 이력, 개인 메모, 주간 과업표 수정 내용이 영구적으로 삭제됩니다. 이후 명단에서 다시 추가될 수 있지만 삭제된 정보는 복구되지 않습니다. 멤버를 삭제했습니다 멤버를 삭제할 수 없습니다 - 탈퇴한 멤버 수동 추가 + 탈퇴한 멤버 추가 메모 날짜 선택 시간 (선택) @@ -135,7 +135,7 @@ 가입/탈퇴 가입 탈퇴 - 불명 + 날짜 알 수 없음 선택한 게임 날짜의 비고 추가 비고 추가 이번 주에는 최대 128개의 비고가 이미 저장되어 있습니다. @@ -159,7 +159,7 @@ HaoPlay 일본 HaoPlay 한국 HaoPlay 아시아 - 새로운 서클 감지 + 새 서클 감지 게임 클라이언트는 확인했지만 패킷만으로 서버를 확정할 수 없습니다. 패킷을 저장하기 전에 서버를 선택하세요. 이 서클의 서버 선택 확인 전까지 파싱된 패킷은 메모리에만 보관됩니다. GF2logger를 강제 종료하면 삭제됩니다. @@ -168,7 +168,7 @@ 계속 선택 수집한 패킷을 버리시겠습니까? 서버 선택을 취소하면 수집한 패킷이 저장되지 않을 수 있습니다. 확인되지 않은 서클의 패킷을 버리시겠습니까? - 서클 관리 + 서클 프로필 관리 감지된 서클의 서버를 수정하거나 격리된 데이터를 완전히 삭제합니다. 선택됨 %1$s 서버 수정 @@ -233,7 +233,7 @@ 완료된 캡처 세션이 없습니다. %1$s\n전달: %2$d KiB · 검사: %3$d KiB\n디코딩: %4$d · 경고: %5$d · 누락 청크: %6$d\n알 수 없는 페이로드 (유형:횟수): %7$s 주간 과업표 설정 - 확인할 기준만 활성화하세요. 활성화한 기준보다 낮은 값은 노란색으로 표시됩니다. + 확인할 기준만 켜세요. 켜 둔 기준보다 낮은 값은 주황색 밑줄로 표시됩니다. 일일 커트라인 주간 커트라인 공적 @@ -316,7 +316,7 @@ %1$s — %2$s CSV 파일을 검증하고 미리보기를 준비하는 중… 체크포인트를 만들고 CSV 파일을 가져오는 중… - CSV 가져오기 완료: 보관 %1$d, 현재 %2$d, 과거 %3$d, 건너뜀 %4$d. 실행 취소할 수 있습니다. + 가져오기 완료: 파일 %1$d개 보관, 현재 %2$d, 과거 스냅샷 %3$d, 건너뜀 %4$d. 홈에서 되돌릴 수 있습니다. 마지막 CSV 가져오기 실행 취소 마지막 CSV 가져오기 직전에 만든 자동 체크포인트를 복원할까요? 가져오기 이후 서클 데이터가 변경되었다면 되돌리기를 거부합니다. 사용 가능한 CSV 가져오기 체크포인트가 없습니다. @@ -347,12 +347,12 @@ 저장된 Discord 웹훅을 제거할 수 없습니다. 원본 CSV 전송 이 원본 CSV 파일을 설정된 Discord 채널로 전송할까요? 캡처된 서클 데이터가 앱 외부로 이동합니다. - 전송하기 전에 설정에서 Discord 수신 웹훅을 구성하세요. + 전송하기 전에 설정에서 Discord 수신 웹훅을 설정하세요. 원본 CSV를 Discord로 전송하는 중… 원본 CSV를 Discord로 전송했습니다. 원본 CSV를 전송할 수 없습니다. 웹훅과 네트워크 연결을 확인하세요. 이 파싱 패킷에는 전송할 수 있는 유효한 CSV 본문이 없습니다. - 표 히스토리 + 표 기록 자동으로 기록된 현재 주간 테이블의 과거 히스토리 목록입니다. 아직 기록된 주간 테이블 변경 이력이 없습니다. 현재 복원된 항목 @@ -365,7 +365,7 @@ 서클 선택 선택 새 서클 프로필 추가 - 신규 서클 정보 입력 + 새 서클 정보 새롭게 등록할 서클의 정보를 입력해 주세요. 클라이언트 서클 이름 @@ -431,7 +431,7 @@ 수동 명단 관측 정확 (동향) - 가입 + 가입 중 최근 스냅샷 탈퇴 숫자 플레이어 UID @@ -526,7 +526,7 @@ 명단 CSV에는 서클 정보가 없습니다. 파일을 넣을 서클을 직접 선택하세요. 가져올 서클 어느 서클로 가져올까요? - 21917 데이터로는 대상을 검증할 수 없습니다. 올바른 서클인지 확인하세요. + 명단 파일에는 서클 정보가 없습니다. 올바른 서클인지 확인하세요. 아직 아무것도 바뀌지 않았습니다. 가져오기 전에 자동 체크포인트를 만들어 되돌릴 수 있습니다. 이미 있음 유효한 파일 @@ -586,4 +586,5 @@ 최근 명단 · %1$s 가입 %1$d 탈퇴 %1$d + 서클을 저장하는 중… diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 1fb821a..f0a32fe 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -8,7 +8,6 @@ #BDB6AC #44453F #858077 - #F5F0E8 #E49B68 #493529 #5A4030 diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 112d950..7aed63a 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -7,23 +7,28 @@ @color/text_primary @color/text_secondary @style/Widget.GF2.TextView - @style/Widget.GF2.Button @style/Widget.GF2.EditText - @color/app_background - @color/app_background false true true @style/GF2DialogTheme + @style/GF2PickerTheme + @style/GF2PickerTheme + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 7e02b15..f747f9a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -6,10 +6,9 @@ #EEEAE3 #E5DFD7 #25231F - #6C665F + #625C55 #DED8CF #918A81 - #25231F #D7824B #F6E4D7 #EDD3C1 @@ -19,12 +18,12 @@ #E4EEE3 #D4E3D3 #3E6747 - #4B7955 + #3E6747 #F4E8D2 #7D5D34 - #966A3B - #A85258 - #A8753F + #7D5D34 + #8E3F45 + #8A5A24 #2426241F #D7824B #2B1D14 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b5d7e4..20c9969 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,7 +11,7 @@ Continuous One-time capture Stop - Open payload options + Open settings Export Platoon backup Stop capture before exporting or importing a backup. Platoon backup exported @@ -79,7 +79,7 @@ Last login No Platoon Members snapshot has been captured yet. No matching members - Merit This Week + Merit this week Total Merit Snapshot comparison Capture at least two complete Platoon Members snapshots to compare changes. @@ -97,13 +97,13 @@ Saved Unable to save Membership history - Add Membership History + Add membership period Add period Delete member - Deleting this member permanently removes saved history, notes, and weekly edits. A future roster packet may add the member again, but the deleted details cannot be recovered. + Deleting this member permanently removes their history, personal notes, and weekly edits. A later roster may add the member again, but the deleted details cannot be recovered. Member deleted Unable to delete member - Manually Add Withdrawn Member + Add withdrawn member Notes Select date Time (optional) @@ -135,7 +135,7 @@ Join / Withdraw Join Withdraw - unknown + Unknown date Add a note for the selected game day Add note This week already contains the maximum 128 notes. @@ -159,7 +159,7 @@ HaoPlay Japan HaoPlay South Korea HaoPlay Asia - New Platoon Detected + New Platoon detected GF2logger verified the game client, but the packet does not identify its server. Choose the server before these packets are saved. Choose this Platoon’s server Until you confirm, these parsed packets remain only in memory. Force-stopping GF2logger discards them. @@ -233,7 +233,7 @@ No completed capture session yet. %1$s\nForwarded: %2$d KiB · inspected: %3$d KiB\nDecoded: %4$d · warnings: %5$d · dropped chunks: %6$d\nUnknown payloads (type:count): %7$s Weekly task table settings - Enable only the cutlines you want to monitor. Values below an enabled cutline are highlighted in yellow. + Turn on only the cutlines you want to watch. Values below an enabled cutline are shown in amber and underlined. Daily cut-off point Weekly cut-off point Merit @@ -320,7 +320,7 @@ %1$s — %2$s Validating CSV files and preparing preview… Creating checkpoint and importing CSV files… - CSV import complete: %1$d retained, %2$d current, %3$d historical, %4$d skipped. Undo is available. + Imported: %1$d files kept, %2$d current, %3$d older snapshots, %4$d skipped. You can undo this from Home. Undo last CSV import Restore the automatic checkpoint from immediately before the last CSV import? Undo is refused if Platoon data changed after that import. No CSV import checkpoint is available. @@ -351,12 +351,12 @@ Unable to remove the saved Discord webhook. Send original CSV Send this original CSV file to the configured Discord channel? This explicitly moves captured Platoon data outside the app. - Configure a Discord incoming webhook in Options before sending. + Set up a Discord incoming webhook in Settings before sending. Sending original CSV to Discord… Original CSV sent to Discord. Unable to send the original CSV. Check the webhook and network connection. This parsed packet does not contain a valid sendable CSV body. - Table History + Table history Automatically recorded past versions of the current weekly table. No table changes have been recorded yet. Currently restored @@ -366,16 +366,16 @@ The recorded weekly table was restored. This table history entry is no longer available. Showing a restored table history entry. New evidence will return this week to the live table. - Select platoon + Select Platoon Select Add a new Platoon profile - Enter New Platoon Information + New Platoon details Enter the identity of the Platoon that will receive this CSV. Client Platoon name Platoon ID Check every field. This client, server, and Platoon ID may already be registered. - No platoon detected + No Platoon detected No Platoon information has been collected yet. HaoPlay capture server Darkwinter capture server @@ -533,7 +533,7 @@ Roster CSV files do not identify their Platoon. Choose where these files belong. Destination Import into which Platoon? - Payload 21917 cannot verify this destination. Make sure it is the right Platoon. + Roster files don\'t record which Platoon they came from. Make sure this is the right one. Nothing has changed yet. Importing creates an automatic checkpoint first, so you can undo it. Already imported Valid files @@ -566,7 +566,7 @@ Replaces only the Platoon inside the backup Restoring replaces the data of the Platoon inside the backup and then selects it. A complete backup also replaces app settings. Other Platoons are unchanged. Keep capture stopped. Roster CSV exported - Keep + Save Saved %1$d/%2$d Send Set up @@ -594,4 +594,5 @@ Latest roster · %1$s joined %1$d withdrew %1$d + Saving this Platoon… diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 6fce432..135cf21 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -9,20 +9,19 @@ @color/text_primary @color/text_secondary @style/Widget.GF2.TextView - @style/Widget.GF2.Button @style/Widget.GF2.EditText - @color/app_background - @color/app_background true true true @style/GF2DialogTheme + @style/GF2PickerTheme + @style/GF2PickerTheme - + + + From 1d48eb5ee58b9f7ac95a38d0474a7cdd8fd8f398 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:58:46 +0900 Subject: [PATCH 063/125] fix: retire consumed CSV import checkpoints atomically --- .../PlatoonBackupManagerIntegrationTest.kt | 29 +++++++++++++++++++ .../management/CsvImportCheckpointManager.kt | 21 ++++++++++++-- 2 files changed, 47 insertions(+), 3 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 fb245da..0ad026f 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -581,6 +581,32 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) } + @Test + fun interruptedCheckpointRetirementNeverBlocksLaterImports() { + seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") + CsvImportCheckpointManager(context, storageScope).runImport(emptySet()) { + replaceDatabaseForCheckpoint(CURRENT_UID, "After import", "after-import.csv") + } + // A process death while deleting a consumed checkpoint leaves only part of its + // retired copy; it must never be mistaken for, or block, a live checkpoint. + val retired = FilePaths.csvCheckpointDiscardedDirectory(context).apply { mkdirs() } + java.io.File(retired, "planned-files.txt").writeText("\n", Charsets.UTF_8) + + val recovered = CsvImportCheckpointManager(context, storageScope) + + assertTrue(recovered.canUndo()) + assertFalse(retired.exists()) + recovered.restore() + assertFalse(FilePaths.csvCheckpointDirectory(context).exists()) + assertFalse(FilePaths.csvCheckpointDiscardedDirectory(context).exists()) + PlatoonDatabase(context).use { database -> + assertEquals(1L, count(database.readableDatabase, "members", "uid = ?", ARCHIVED_UID)) + } + val next = CsvImportCheckpointManager(context, storageScope) + next.runImport(emptySet()) {} + assertTrue(next.canUndo()) + } + @Test fun unfinishedCsvImportRecoversBeforePreviewRepositoryReads() { seedDatabase(ARCHIVED_UID, "Before interrupted import", "before-interrupted-import.csv") @@ -989,6 +1015,9 @@ class PlatoonBackupManagerIntegrationTest { fun csvCheckpointPreviousDirectory(context: Context) = java.io.File(scope(context).rootDirectory(context), "csv-import-checkpoint.previous") + + fun csvCheckpointDiscardedDirectory(context: Context) = + java.io.File(scope(context).rootDirectory(context), "csv-import-checkpoint.discarded") } private class SimulatedProcessDeath : Error() diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt index c542a54..43066c8 100644 --- a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt @@ -35,6 +35,7 @@ class CsvImportCheckpointManager internal constructor( private val root = File(profileRoot, CHECKPOINT_DIRECTORY) private val staging = File(profileRoot, STAGING_DIRECTORY) private val previous = File(profileRoot, PREVIOUS_DIRECTORY) + private val discarded = File(profileRoot, DISCARDED_DIRECTORY) /** Claims are per scope: an operation in one Platoon never blocks another Platoon's import. */ private val scopeState = synchronized(SCOPE_STATES) { @@ -118,7 +119,7 @@ class CsvImportCheckpointManager internal constructor( } finally { temporary.delete() } - deleteDirectory(previous) + discard(previous) } finally { releaseOperation() } @@ -216,7 +217,7 @@ class CsvImportCheckpointManager internal constructor( restoreQuarantinedFiles(names, retained, quarantine, error) throw error } - deleteDirectory(root) + discard(root) restorePreviousCheckpoint() } @@ -230,6 +231,7 @@ class CsvImportCheckpointManager internal constructor( // - Unit after checkpoint state is usable or the interrupted mutation is rolled back. private fun recoverInterruptedState() { deleteDirectory(staging) + deleteDirectory(discarded) if (!root.exists()) { restorePreviousCheckpoint() return @@ -239,7 +241,7 @@ class CsvImportCheckpointManager internal constructor( if (!digest(root).isFile || restoreMarker(root).isFile || quarantine.exists()) { restoreUnchecked() } else if (previous.exists()) { - deleteDirectory(previous) + discard(previous) } } @@ -326,6 +328,18 @@ class CsvImportCheckpointManager internal constructor( require(name.matches(SAFE_IMPORT_NAME)) { "Invalid CSV checkpoint identity" } } + /** + * Retires a checkpoint directory with one atomic rename before deleting it. A recursive delete + * interrupted midway would otherwise leave a live-looking checkpoint without its archive. + * A leftover retired copy is inert and removed again by the next recovery. + */ + private fun discard(directory: File) { + if (!directory.exists()) return + deleteDirectory(discarded) + check(directory.renameTo(discarded)) { "Unable to retire CSV import checkpoint state" } + discarded.deleteRecursively() + } + private fun deleteDirectory(directory: File) { if (directory.exists()) check(directory.deleteRecursively()) { "Unable to remove CSV import checkpoint state" @@ -343,6 +357,7 @@ class CsvImportCheckpointManager internal constructor( const val CHECKPOINT_DIRECTORY = "csv-import-checkpoint" const val STAGING_DIRECTORY = "csv-import-checkpoint.staging" const val PREVIOUS_DIRECTORY = "csv-import-checkpoint.previous" + const val DISCARDED_DIRECTORY = "csv-import-checkpoint.discarded" const val QUARANTINE_DIRECTORY = "retained-quarantine" const val ARCHIVE_FILE = "platoon.gf2backup" const val MANIFEST_FILE = "planned-files.txt" From 89185fbeac44f1f9ff2a8867047c8b78a6fba42f Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 14:59:30 +0900 Subject: [PATCH 064/125] fix: restore the exact scoped timezone when a server change fails --- .../PlatoonProfileRegistryIntegrationTest.kt | 31 +++++++++++++++++++ .../PlatoonProfileAdministration.kt | 29 +++++++++-------- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt index 20ff32e..4340c34 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonProfileRegistryIntegrationTest.kt @@ -5,6 +5,7 @@ import dev.gf2log.app.ActivePlatoonScopeBinding import dev.gf2log.app.SupportedGamePackages import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.settings.ClientServerRegionPreferences +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences import dev.gf2log.app.settings.WeeklyCutlines @@ -390,6 +391,36 @@ class PlatoonProfileRegistryIntegrationTest { ) } + @Test + fun failedServerRegionChangeRestoresAManualTimezoneExactly() { + val registry = PlatoonProfileRegistry(context) + val profile = registry.upsertDetected( + SupportedGamePackages.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + PlatoonProfileData(101818u, "Manual zone", 0u, 0u), + ) + // A schema-v1-v3 complete backup restores a manual timezone for its Platoon. + val manualZone = java.time.ZoneId.of("America/New_York") + GameTimeZonePreferences.set(context, manualZone, profile.storageId) + var rebuilds = 0 + val administration = PlatoonProfileAdministration(context) { _, _, _ -> + rebuilds += 1 + check(rebuilds > 1) { "Simulated weekly-history rebuild failure" } + } + + assertThrows(IllegalStateException::class.java) { + administration.changeServerRegion(profile.storageId, GameServerRegion.HAOPLAY_JAPAN) + } + + assertEquals(2, rebuilds) + assertEquals(GameServerRegion.HAOPLAY_KOREA, registry.find(profile.storageId)?.serverRegion) + assertEquals( + GameServerRegion.MANUAL, + GameTimeZonePreferences.region(context, profile.storageId), + ) + assertEquals(manualZone, GameTimeZonePreferences.get(context, profile.storageId)) + } + @Test fun restoreCannotRegisterOneFullIdentityUnderTwoStorageScopes() { val registry = PlatoonProfileRegistry(context) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt index ccfd8b4..a254111 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt @@ -6,40 +6,43 @@ import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences +import java.time.ZoneId /** Owns destructive and derived-data maintenance for isolated Platoon profiles. */ -internal class PlatoonProfileAdministration(context: Context) { +internal class PlatoonProfileAdministration internal constructor( + context: Context, + private val rebuildWeeklyHistory: (Context, PlatoonStorageScope, ZoneId) -> Unit, +) { + constructor(context: Context) : this(context, { appContext, scope, zone -> + PlatoonRepository(appContext, scope).rebuildWeeklyHistoryForTimeZoneChange(zone) + }) + private val appContext = context.applicationContext private val registry = PlatoonProfileRegistry(appContext) /** * Updates verified server metadata while retaining the profile's immutable storage scope. - * Weekly history is rebuilt with the new reset zone and rolled back if rebuilding fails. + * Weekly history is rebuilt with the new reset zone. If rebuilding fails, the registry entry + * and the exact scoped timezone keys, including a restored manual zone, are put back. */ fun changeServerRegion(storageId: String, region: GameServerRegion): PlatoonProfile { val before = requireNotNull(registry.find(storageId)) { "Unknown Platoon profile" } require(!before.legacy) if (before.serverRegion == region) return before - val wasAutomatic = GameTimeZonePreferences.isAutomatic(appContext, storageId) - val previousOverride = GameTimeZonePreferences.region(appContext, storageId) + val scope = PlatoonStorageScope(storageId) + val previousZoneState = GameTimeZonePreferences.scopedState(appContext, storageId) val previousZone = GameTimeZonePreferences.get(appContext, storageId) val updated = registry.updateServerRegion(storageId, region) return try { GameTimeZonePreferences.clearRegionOverride(appContext, storageId) - PlatoonRepository(appContext, PlatoonStorageScope(storageId)) - .rebuildWeeklyHistoryForTimeZoneChange(requireNotNull(region.serverZone)) + rebuildWeeklyHistory(appContext, scope, requireNotNull(region.serverZone)) updated } catch (error: Exception) { runCatching { registry.updateServerRegion(storageId, before.serverRegion) } .onFailure(error::addSuppressed) runCatching { - if (wasAutomatic) { - GameTimeZonePreferences.clearRegionOverride(appContext, storageId) - } else { - GameTimeZonePreferences.setRegion(appContext, previousOverride, storageId) - } - PlatoonRepository(appContext, PlatoonStorageScope(storageId)) - .rebuildWeeklyHistoryForTimeZoneChange(previousZone) + GameTimeZonePreferences.restoreScopedState(appContext, storageId, previousZoneState) + rebuildWeeklyHistory(appContext, scope, previousZone) }.onFailure(error::addSuppressed) throw error } From 55f7c4033f098a7d3467b2c5039dfc55f9abb016 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:00:28 +0900 Subject: [PATCH 065/125] fix: keep the per-capture admission limit across service restarts --- .../dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt | 8 +++++++- .../java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) 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 7609ad6..7406036 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt @@ -29,7 +29,6 @@ internal class PlatoonAdmissionCoordinator( } private val tokenByFlow = HashMap() - private val admissionGate = PlatoonProfilePolicy.AdmissionGate() fun beginSession() = admissionGate.clear() @@ -207,5 +206,12 @@ internal class PlatoonAdmissionCoordinator( companion object { const val MAX_STATUS_NAME_CHARS = 40 + + /** + * Process-wide like the pending candidates it limits: the service, and with it this + * coordinator, is recreated after capture stops, while candidates from that capture can + * still be confirmed. A per-instance gate would reset and admit every one of them. + */ + private val admissionGate = PlatoonProfilePolicy.AdmissionGate() } } diff --git a/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt b/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt index 4206df7..9232ae9 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonProfilePolicy.kt @@ -24,13 +24,16 @@ internal object PlatoonProfilePolicy { internal class AdmissionGate { private val admittedClients = mutableSetOf() + @Synchronized fun canAdmit(ownerPackage: String, alreadyRegistered: Boolean): Boolean = alreadyRegistered || ownerPackage !in admittedClients + @Synchronized fun markAdmitted(ownerPackage: String) { admittedClients += ownerPackage } + @Synchronized fun clear() = admittedClients.clear() } } From cde42434cfbe6a96395f44d130c8a38f1f8a7499 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:01:18 +0900 Subject: [PATCH 066/125] refactor: remove unused database, history, and import helpers --- .../app/management/PlatoonCsvImportStore.kt | 7 ------- .../gf2log/app/management/PlatoonDatabase.kt | 18 ------------------ .../app/management/PlatoonReadQueries.kt | 6 ------ .../app/management/RosterHistoryReplay.kt | 1 - .../app/management/WeeklyHistoryStore.kt | 4 ---- 5 files changed, 36 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 b8fd25e..c489006 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt @@ -93,13 +93,6 @@ class PlatoonCsvImportStore(private val directory: File) { return RetainResult(file, duplicate = false) } - fun isRetained(prepared: PreparedImport): Boolean { - val file = File(directory, prepared.fileName) - if (!file.isFile) return false - require(file.readBytes().contentEquals(prepared.encoded)) { "CSV identity collision" } - return true - } - private fun readBounded(input: InputStream): ByteArray { val output = ByteArrayOutputStream() val buffer = ByteArray(READ_BUFFER_BYTES) 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 3fcd323..b587d11 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -52,12 +52,6 @@ internal class PlatoonDatabase private constructor( override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = PlatoonDatabaseMigrations.upgrade(db, oldVersion, ::gameZone) - /** Returns true only when this database contains user-visible management state. */ - internal fun hasManagementData(): Boolean { - val db = readableDatabase - return MANAGEMENT_DATA_TABLES.any { table -> PlatoonReadQueries.count(db, table) > 0L } - } - @Synchronized internal fun runInTransaction(block: () -> T): T = writableDatabase.inTransaction(block) @@ -254,9 +248,6 @@ internal class PlatoonDatabase private constructor( fun clearActiveWeeklyReportHistory(periodStartEpochDay: Long): Boolean = WeeklyHistoryStore.clearActive(writableDatabase, periodStartEpochDay) - @Synchronized - fun clearWeeklyReportHistory() = WeeklyHistoryStore.clear(writableDatabase) - @Synchronized internal fun replaceWeeklyReportHistory(replacements: List) = WeeklyHistoryStore.replace(writableDatabase, replacements) @@ -274,14 +265,5 @@ internal class PlatoonDatabase private constructor( const val DAILY_PATROL_REWARD_ACTION_ID = 802001L internal const val MAX_STORED_ACTIVITY_OBSERVATIONS = 10_000 internal const val MAX_WEEKLY_REPORT_HISTORY = 15 - private val MANAGEMENT_DATA_TABLES = listOf( - "snapshots", - "members", - "membership_periods", - "member_events", - "platoon_activity", - "weekly_notes", - "weekly_overrides", - ) } } 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 0d08932..a9b5a31 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt @@ -218,12 +218,6 @@ internal object PlatoonReadQueries { arrayOf(membershipPeriodId.toString()), ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } - fun count(db: SQLiteDatabase, table: String): Long = - db.rawQuery("SELECT COUNT(*) FROM $table", null).use { cursor -> - cursor.moveToFirst() - cursor.getLong(0) - } - private fun Cursor.toMemberEvent(): MemberEvent = MemberEvent( id = getLong(0), uid = getLong(1), diff --git a/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt b/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt index 1bf7197..5d059d4 100644 --- a/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt +++ b/app/src/main/java/dev/gf2log/app/management/RosterHistoryReplay.kt @@ -57,7 +57,6 @@ internal object RosterHistoryReplay { } private fun ensureHistoricalMembers(db: SQLiteDatabase, snapshots: List) { - val latestFrame = snapshots.last() snapshots.flatMap { frame -> frame.members.map { (uid, identity) -> Triple(uid, identity, frame.capturedAt) } }.groupBy { it.first }.forEach { (uid, observations) -> 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 037a170..c2cc65f 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryStore.kt @@ -130,10 +130,6 @@ internal object WeeklyHistoryStore { arrayOf(periodStartEpochDay.toString()), ) > 0 - fun clear(db: SQLiteDatabase) { - db.inTransaction { deleteAll(db) } - } - /** * Replaces every derived weekly revision in one SQLite transaction, validating the complete * replacement before deleting existing history. From e156a77bd215c36f3ddd15d198ae5c29982a8080 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:01:44 +0900 Subject: [PATCH 067/125] feat: open the week at today and smooth roster and settings flows - Weekly grid starts scrolled to today's column and keeps the user's position on re-render - Platoon search gets a clear button; text-change listeners share one helper - Home refreshes installed clients on return; time pickers follow the 24-hour setting - Sorting the member order asks before replacing a hand-arranged order - Restore is styled as a destructive action; section headers use neutral ink - Byte sizes round up; segmented control no longer reposts while unmeasured --- .../main/java/dev/gf2log/app/CsvImportFlow.kt | 2 +- .../dev/gf2log/app/DateTimePickerInput.kt | 13 ++----- .../main/java/dev/gf2log/app/MainActivity.kt | 10 ++++- .../dev/gf2log/app/MemberDetailActivity.kt | 12 ++---- .../dev/gf2log/app/MemberOrderActivity.kt | 7 +++- .../java/dev/gf2log/app/OptionsActivity.kt | 2 +- .../java/dev/gf2log/app/PlatoonActivity.kt | 18 ++------- .../dev/gf2log/app/PlatoonCsvImportPrompt.kt | 11 ++---- .../dev/gf2log/app/PlatoonProfileSelector.kt | 15 +------- .../main/java/dev/gf2log/app/WeeklyTable.kt | 22 ++++++++++- .../main/java/dev/gf2log/app/ui/Controls.kt | 6 +-- app/src/main/java/dev/gf2log/app/ui/Views.kt | 37 ++++++++++++++++++- app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 14 files changed, 94 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt index 8d01b3d..886ed36 100644 --- a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt +++ b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt @@ -211,7 +211,7 @@ internal class CsvImportFlow( R.string.csv_preview_name_changes to preview.nameDifferences.toString(), R.string.csv_preview_joins to preview.potentialJoins.toString(), R.string.csv_preview_withdrawals to preview.potentialWithdrawals.toString(), - R.string.csv_preview_size to activity.getString(R.string.kib, preview.totalBytes / 1024), + R.string.csv_preview_size to activity.getString(R.string.kib, (preview.totalBytes + 1023) / 1024), R.string.csv_preview_range to range, ) val content = Dialogs.content(activity) { diff --git a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt index 5bc6e87..042ac84 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.ui.Labels import dev.gf2log.app.ui.TextRole import dev.gf2log.app.ui.dp import dev.gf2log.app.ui.dpf @@ -20,7 +21,6 @@ import java.time.Instant import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId -import java.time.format.DateTimeFormatter /** * Calendar input with an optional clock value. @@ -117,16 +117,16 @@ internal class DateTimePickerInput( }, seed.hour, seed.minute, - true, + android.text.format.DateFormat.is24HourFormat(context), ).show() } private fun renderValue() { val date = selection.date val time = selection.time - dateField.text = date?.format(DATE) ?: context.getString(R.string.date_unset) + dateField.text = date?.format(Labels.DATE) ?: context.getString(R.string.date_unset) dateField.setTextColor(context.getColor(if (date == null) R.color.text_secondary else R.color.text_primary)) - timeField.text = time?.format(TIME) ?: context.getString(R.string.time_optional) + timeField.text = time?.format(Labels.TIME) ?: context.getString(R.string.time_optional) timeField.setTextColor(context.getColor(if (time == null) R.color.text_secondary else R.color.text_primary)) dateField.isEnabled = editable timeField.isEnabled = editable && date != null @@ -140,9 +140,4 @@ internal class DateTimePickerInput( if (time != null) R.string.clear_time else R.string.clear_date, ) } - - private companion object { - val DATE: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE - val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") - } } diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index a4e18b0..08b90b3 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -79,6 +79,7 @@ class MainActivity : BaseActivity() { private lateinit var stateDetail: TextView private lateinit var checklist: LinearLayout private lateinit var captureActions: LinearLayout + private lateinit var clients: LinearLayout private lateinit var progressBanner: LinearLayout private lateinit var progressText: TextView private lateinit var historySection: LinearLayout @@ -177,6 +178,7 @@ class MainActivity : BaseActivity() { recreate() return } + if (::clients.isInitialized) renderClients() loadHistory() loadRosterSummary() csvImport.onScreenReady() @@ -243,6 +245,8 @@ class MainActivity : BaseActivity() { stateDetail = text(null, TextRole.SECONDARY) { maxLines = 4 } checklist = column { visibility = View.GONE } captureActions = row(Gravity.TOP) + clients = column() + renderClients() capturePanel = panel(outlined = true) { addView(row { addView(stateDot, lp(10, 10, end = 8)) @@ -251,13 +255,15 @@ class MainActivity : BaseActivity() { addView(stateTitle, lp(top = 6)) addView(stateDetail, lp(top = 2)) addView(checklist, lp(top = 8)) - addView(clientRows(), lp(top = 12)) + addView(clients, lp(top = 12)) addView(captureActions, lp(top = 12)) } return capturePanel } - private fun clientRows(): View = column { + /** Rebuilt on every resume so installing or removing a client shows up immediately. */ + private fun renderClients() = clients.apply { + removeAllViews() SupportedGamePackages.all.forEachIndexed { index, packageName -> val installed = isInstalled(packageName) val client = clientName(packageName) diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 7c357d2..aa1580f 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -2,8 +2,6 @@ package dev.gf2log.app import android.app.AlertDialog import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher import android.view.Gravity import android.view.View import android.widget.Button @@ -17,6 +15,7 @@ import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.management.isImmutableMembershipBoundary import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.ui.onTextChanged import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs @@ -105,13 +104,8 @@ class MemberDetailActivity : BaseActivity() { nickname = textField(getString(R.string.member_nickname), id = R.id.member_nickname_input, maxLength = 256) note = textField(getString(R.string.member_private_note_hint), id = R.id.member_note_input, singleLine = false) saveButton = button(getString(R.string.save_member), Controls.Role.PRIMARY) { saveProfile() } - val watcher = object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = updateSaveState() - override fun afterTextChanged(s: Editable?) = Unit - } - nickname.addTextChangedListener(watcher) - note.addTextChangedListener(watcher) + nickname.onTextChanged { updateSaveState() } + note.onTextChanged { updateSaveState() } statusPanel = panel() periods = column() content = column { diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index 7e46ac7..a716975 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -108,7 +108,7 @@ class MemberOrderActivity : BaseActivity() { addView(fieldButton, lp(width = 0, height = 48, weight = 1.4f)) addView(directionButton, lp(width = 0, height = 48, weight = 1f, start = 8)) }, lp(top = 8)) - addView(button(getString(R.string.apply_sort), Controls.Role.SECONDARY) { applySort() }, lp(top = 8)) + addView(button(getString(R.string.apply_sort), Controls.Role.SECONDARY) { confirmSort() }, lp(top = 8)) }, lp(top = 12, bottom = 8)) updateSortLabels() } @@ -186,6 +186,11 @@ class MemberOrderActivity : BaseActivity() { MemberSortField.TOTAL_MERIT -> R.string.sort_total_merit } + /** Sorting replaces any hand-arranged order, so it is confirmed first. */ + private fun confirmSort() { + Dialogs.confirm(this, R.string.apply_sort, R.string.apply_sort_warning, R.string.apply_sort) { applySort() } + } + private fun applySort() { members = MemberOrderSorter.sort(members, latestMembers, sortField, sortDirection).toMutableList() adapter.notifyDataSetChanged() diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 7651d71..480d45f 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -331,7 +331,7 @@ class OptionsActivity : BaseActivity() { Feedback.show(this, R.string.stop_capture_before_backup, long = true) return } - Dialogs.confirm(this, R.string.restore_full_backup, R.string.restore_backup_warning, R.string.choose_file) { + Dialogs.confirm(this, R.string.restore_full_backup, R.string.restore_backup_warning, R.string.choose_file, destructive = true) { restoreBackup.launch(arrayOf(PlatoonBackupManager.MIME_TYPE, "application/zip", "application/octet-stream")) } } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index fe3d933..0cff639 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -3,10 +3,8 @@ package dev.gf2log.app import android.content.Intent import android.content.res.ColorStateList import android.os.Bundle -import android.text.Editable import android.text.InputType import android.text.TextUtils -import android.text.TextWatcher import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -28,6 +26,7 @@ import dev.gf2log.app.management.PlatoonSnapshot import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.ui.searchField import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.AccessibleRow import dev.gf2log.app.ui.BackgroundWork @@ -181,17 +180,8 @@ class PlatoonActivity : BaseActivity() { } private fun buildHeader(query: String): View { - searchInput = textField(getString(R.string.search_members), id = R.id.platoon_search_input).apply { - setText(query) - setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_search, 0, 0, 0) - compoundDrawablePadding = dp(8) - compoundDrawableTintList = ColorStateList.valueOf(getColor(R.color.text_secondary)) - addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = renderMembers() - override fun afterTextChanged(s: Editable?) = Unit - }) - } + val search = searchField(getString(R.string.search_members), R.id.platoon_search_input, query) { renderMembers() } + searchInput = search.findViewById(R.id.platoon_search_input) sortButton = text(null, TextRole.LABEL) { setTextColor(getColor(R.color.text_primary)) gravity = Gravity.CENTER_VERTICAL @@ -211,7 +201,7 @@ class PlatoonActivity : BaseActivity() { return column { addView(PlatoonProfileSelector.card(this@PlatoonActivity), lp(top = 4)) addView(summary, lp(top = 12)) - addView(searchInput, lp(top = 16)) + addView(search, lp(top = 16)) addView(Controls.segmentedControl( context = this@PlatoonActivity, options = Filter.entries.map { it.name to getString(it.label) }, diff --git a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt index 5fa9fab..d2dffe8 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt @@ -2,9 +2,7 @@ package dev.gf2log.app import android.app.Activity import android.app.AlertDialog -import android.text.Editable import android.text.InputType -import android.text.TextWatcher import android.view.View import android.widget.LinearLayout import android.widget.RadioButton @@ -14,6 +12,7 @@ import dev.gf2log.app.management.PlatoonProfile import dev.gf2log.app.management.PlatoonProfileRegistry import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.ui.onTextChanged import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.LabeledField @@ -133,16 +132,12 @@ internal object PlatoonCsvImportPrompt { .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.add, null), ) { shown -> - val watcher = object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + listOf(name.input, platoonId.input).forEach { field -> + field.onTextChanged { error.visibility = View.GONE updateEnabled() } - override fun afterTextChanged(s: Editable?) = Unit } - name.input.addTextChangedListener(watcher) - platoonId.input.addTextChangedListener(watcher) shown.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> val selectedRegion = region ?: return@setOnClickListener val id = validId() ?: return@setOnClickListener diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index 325848c..3442ad6 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -3,10 +3,8 @@ package dev.gf2log.app import android.app.Activity import android.app.AlertDialog import android.graphics.drawable.GradientDrawable -import android.text.Editable import android.text.InputType import android.text.TextUtils -import android.text.TextWatcher import android.view.Gravity import android.view.View import android.widget.ImageView @@ -16,6 +14,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.ui.onTextChanged import dev.gf2log.app.ui.AccessibleRow import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.captureActive @@ -80,10 +79,6 @@ internal object PlatoonProfileSelector { } } - /** Transitional entry point for screens that still embed the selector inline. */ - fun controls(activity: Activity, compact: Boolean = false, showManageButton: Boolean = true): View = - card(activity, compact) - fun showSelector(activity: Activity) { val registry = PlatoonProfileRegistry(activity) val profiles = registry.list() @@ -273,13 +268,7 @@ internal object PlatoonProfileSelector { } } } - input.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - delete.isEnabled = s?.toString() == profile.platoonName - } - override fun afterTextChanged(s: Editable?) = Unit - }) + input.onTextChanged { delete.isEnabled = it == profile.platoonName } } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt index f21969d..3e0d5a3 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt @@ -43,10 +43,20 @@ internal class HorizontalSync { views[view] = Unit } + private var placedFor: Any? = null + fun publish(source: SyncedScrollView, x: Int) { offset = x views.keys.forEach { if (it !== source) it.syncTo(x) } } + + /** Sets the start position once per [key], so re-renders of the same week keep the user's scroll. */ + fun placeOnce(key: Any, x: Int) { + if (placedFor == key) return + placedFor = key + offset = x + views.keys.forEach { it.syncTo(x) } + } } internal class SyncedScrollView(context: Context, private val sync: HorizontalSync) : HorizontalScrollView(context) { @@ -111,7 +121,14 @@ internal class WeeklyTable( val rowHeight = height(if (isGunsmokeWeek) GUNSMOKE_ROW_HEIGHT else STANDARD_ROW_HEIGHT) - fun headerRow(report: WeeklyReportBuilder.Report): View = gridRow( + fun headerRow(report: WeeklyReportBuilder.Report): View { + // Open the current week at yesterday so today's column is on screen without swiping. + val todayIndex = report.days.indexOf(PlatoonPeriods.gameDay(now, zone)) + sync.placeOnce(report.days.first(), (width(DAY_WIDTH) + hairline) * (todayIndex - 1).coerceAtLeast(0)) + return headerGridRow(report) + } + + private fun headerGridRow(report: WeeklyReportBuilder.Report): View = gridRow( frozen = headerCell(context.getString(R.string.member), null, MEMBER_WIDTH, Gravity.START or Gravity.CENTER_VERTICAL), cells = report.days.map { day -> headerCell( @@ -378,8 +395,9 @@ internal class WeeklyTable( } /** Grid row: hairline separators come from a 1px gap over the outline-colored row. */ + private val hairline = maxOf(1, context.dp(1) / 2) + private fun gridRow(frozen: View, cells: List, height: Int): View { - val hairline = maxOf(1, context.dp(1) / 2) val scroller = SyncedScrollView(context, sync).apply { addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL diff --git a/app/src/main/java/dev/gf2log/app/ui/Controls.kt b/app/src/main/java/dev/gf2log/app/ui/Controls.kt index 7ef059c..06efc65 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Controls.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Controls.kt @@ -183,10 +183,8 @@ internal object Controls { fun moveSelection(index: Int, animated: Boolean, finished: (() -> Unit)? = null) { val segment = choices.getChildAt(index) - if (segment == null || segment.width <= 0) { - rail.post { moveSelection(index, animated, finished) } - return - } + // Not laid out yet: the rail's layout listener places the thumb once widths exist. + if (segment == null || segment.width <= 0) return selection.layoutParams = (selection.layoutParams as FrameLayout.LayoutParams).apply { width = segment.width } val target = segment.left.toFloat() selection.animate().cancel() 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 baddd1c..b2d634c 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Views.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -105,7 +105,8 @@ internal fun TextView.applyRole(role: TextRole) { /** Section label used above grouped rows. */ internal fun Context.sectionHeader(value: CharSequence): TextView = text(value, TextRole.LABEL) { isAllCaps = false - setTextColor(getColor(R.color.accent_text)) + // Neutral ink: the accent stays reserved for things that can be tapped or are selected. + setTextColor(getColor(R.color.text_secondary)) setPadding(dp(4), dp(20), dp(4), dp(6)) isFocusable = false accessibilityHeading() @@ -183,6 +184,40 @@ internal fun Context.textField( elevation = 0f } +/** Runs [action] with the new text on every edit. */ +internal fun EditText.onTextChanged(action: (String) -> Unit) { + addTextChangedListener(object : android.text.TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = action(s?.toString().orEmpty()) + override fun afterTextChanged(s: android.text.Editable?) = Unit + }) +} + +/** Search input with a leading magnifier and a trailing clear button shown while text is present. */ +internal fun Context.searchField(hintText: CharSequence, @IdRes id: Int, query: String, onQuery: (String) -> Unit): View { + val input = textField(hintText, id = id).apply { + setText(query) + setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_search, 0, 0, 0) + compoundDrawablePadding = dp(8) + compoundDrawableTintList = ColorStateList.valueOf(getColor(R.color.text_secondary)) + imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH + setPaddingRelative(paddingStart, paddingTop, dp(48), paddingBottom) + } + val clear = iconButton(R.drawable.ic_close, getString(R.string.clear_search)) { + input.setText("") + input.requestFocus() + } + clear.visibility = if (query.isEmpty()) View.GONE else View.VISIBLE + input.onTextChanged { text -> + clear.visibility = if (text.isEmpty()) View.GONE else View.VISIBLE + onQuery(text) + } + return FrameLayout(this).apply { + addView(input, FrameLayout.LayoutParams(MATCH, WRAP, Gravity.CENTER_VERTICAL)) + addView(clear, FrameLayout.LayoutParams(dp(48), dp(48), Gravity.END or Gravity.CENTER_VERTICAL)) + } +} + /** Labeled input with an optional supporting or error line. */ internal class LabeledField( context: Context, diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e129a76..c873d8f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -587,4 +587,6 @@ 가입 %1$d 탈퇴 %1$d 서클을 저장하는 중… + 검색어 지우기 + 직접 옮긴 순서를 포함해 현재 순서를 모두 바꿉니다. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20c9969..de867e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -595,4 +595,6 @@ joined %1$d withdrew %1$d Saving this Platoon… + Clear search + This replaces the current order, including any members you moved by hand. From 9865b598ff4a71dcd27594f6319d591f105856ab Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:02:37 +0900 Subject: [PATCH 068/125] refactor: replace verbose function comment blocks with concise KDoc --- .../app/management/ActivityInference.kt | 32 +++---- .../management/CsvImportCheckpointManager.kt | 36 +++---- .../app/management/GunsmokeWeekSolver.kt | 94 ++++++------------- .../app/management/PlatoonBackupManager.kt | 35 ++----- .../app/management/PlatoonCsvImportStore.kt | 26 ++--- .../management/WeeklyMetricPresentation.kt | 15 +-- .../app/management/GunsmokeWeekSolverTest.kt | 21 ++--- 7 files changed, 85 insertions(+), 174 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt b/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt index 8fada47..3ad67db 100644 --- a/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt +++ b/app/src/main/java/dev/gf2log/app/management/ActivityInference.kt @@ -41,16 +41,12 @@ object ActivityInference { ) } - // Function Name: candidatesForScore - // Description: - // - Enumerates every valid Gunsmoke activity state for a captured score. - // - Supports a missing merit boundary without inventing one specific attendance or patrol state. - // - Uses the captured single-attempt high score as an optional aggregate-score ceiling. - // Parameters: - // - scoreDelta: Gunsmoke score accumulated in the current game day. - // - maximumAttemptScore: Largest possible score for one observed attempt, or null if unavailable. - // Returns: - // - Returns every compatible merit, attempt, attendance, patrol, and score-merit combination. + /** + * Enumerates every valid Gunsmoke activity state for a captured score. Supports a missing merit + * boundary without inventing one specific attendance or patrol state. Uses the captured + * single-attempt high score as an optional aggregate-score ceiling. Returns every compatible + * merit, attempt, attendance, patrol, and score-merit combination. + */ fun candidatesForScore( scoreDelta: Long, maximumAttemptScore: Long? = null, @@ -92,17 +88,11 @@ object ActivityInference { }.distinct() } - // Function Name: isCompatibleScore - // Description: - // - Verifies the exact aggregate score-to-merit relationship for a known attempt count. - // - Accounts for per-attempt integer rounding and an optional captured high-score ceiling. - // Parameters: - // - scoreDelta: Aggregate score earned by the attempts. - // - scoreMerit: Sum of the per-attempt score merit after integer division by ten. - // - attempts: Number of attempts represented by the aggregate. - // - maximumAttemptScore: Captured single-attempt upper bound, or null when unavailable. - // Returns: - // - True only when at least one per-attempt score distribution satisfies every constraint. + /** + * Verifies the exact aggregate score-to-merit relationship for a known attempt count. Accounts + * for per-attempt integer rounding and an optional captured high-score ceiling. Returns true + * only when at least one per-attempt score distribution satisfies every constraint. + */ internal fun isCompatibleScore( scoreDelta: Long, scoreMerit: Long, diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt index 43066c8..d0c483d 100644 --- a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt @@ -53,14 +53,10 @@ class CsvImportCheckpointManager internal constructor( } private fun hasBaseCheckpoint(): Boolean = archive(root).isFile && manifest(root).isFile - // Function Name: create - // Description: - // - Exports the current database before any selected CSV is retained. - // - Atomically publishes the archive with the deterministic file identities the import may add. - // Parameters: - // - plannedFileNames: Validated retained CSV identities for non-duplicate selected files. - // Returns: - // - Unit after a durable provisional checkpoint is published beside its predecessor. + /** + * Exports the current database before any selected CSV is retained. Atomically publishes the + * archive with the deterministic file identities the import may add. + */ fun create(plannedFileNames: Set) { claimOperation() var published = false @@ -143,14 +139,10 @@ class CsvImportCheckpointManager internal constructor( } } - // Function Name: restore - // Description: - // - Quarantines only files planned by the reverted import, then restores the database. - // - Restores quarantined evidence if database replacement fails. - // Parameters: - // - None. - // Returns: - // - Unit after the checkpoint is consumed. + /** + * Quarantines only files planned by the reverted import, then restores the database. Restores + * quarantined evidence if database replacement fails. + */ fun restore() { claimOperation() try { @@ -221,14 +213,10 @@ class CsvImportCheckpointManager internal constructor( restorePreviousCheckpoint() } - // Function Name: recoverInterruptedState - // Description: - // - Restores an interrupted checkpoint publication, import rollback, or undo operation. - // - Keeps a sealed latest checkpoint and discards its obsolete predecessor. - // Parameters: - // - None. - // Returns: - // - Unit after checkpoint state is usable or the interrupted mutation is rolled back. + /** + * Restores an interrupted checkpoint publication, import rollback, or undo operation. Keeps a + * sealed latest checkpoint and discards its obsolete predecessor. + */ private fun recoverInterruptedState() { deleteDirectory(staging) deleteDirectory(discarded) 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 a7ed44b..f9771dd 100644 --- a/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/GunsmokeWeekSolver.kt @@ -17,19 +17,11 @@ import java.time.ZoneId * usable evidence. Only values shared by every compatible path are published. */ internal object GunsmokeWeekSolver { - // Function Name: resolve - // Description: - // - Solves daily Gunsmoke states and preserves whole-week totals shared by every valid path. - // - Keeps daily exactness independent from aggregate exactness when only the day placement varies. - // Parameters: - // - uid: Member whose counter history is being reconciled. - // - days: Seven game days in report order. - // - zoneId: Game-day timezone used for 05:00 and final-event boundaries. - // - snapshots: Available structured roster counter observations. - // - cells: Conservative daily projections built from direct boundaries. - // - dailyPatrolFacts: UID-safe timestamped Daily Patrol evidence. - // Returns: - // - Returns resolved daily cells and an optional final-week aggregate consensus. + /** + * Solves daily Gunsmoke states and preserves whole-week totals shared by every valid path. + * Keeps daily exactness independent from aggregate exactness when only the day placement + * varies. Returns resolved daily cells and an optional final-week aggregate consensus. + */ fun resolve( uid: Long, days: List, @@ -115,21 +107,12 @@ internal object GunsmokeWeekSolver { ) } - // Function Name: resolveFinalEventTotals - // Description: - // - Computes conservative whole-week activity totals when detailed path expansion reaches its budget. - // - Uses the Monday reset to establish Sunday and the final event counters to solve six-day aggregates. - // - Never assigns an aggregate possibility to a specific day. - // Parameters: - // - days: Seven report days from Sunday through Saturday. - // - zoneId: Game-day timezone for timestamp evidence. - // - anchor: Optional capture immediately before the Sunday boundary. - // - periodStart: Sunday report boundary instant. - // - checkpoints: One capture for every report day. - // - cells: Current conservative daily projections. - // - factsByDay: UID-safe Daily Patrol evidence grouped by game day. - // Returns: - // - Minimum or exact aggregate values compatible with all end-event arithmetic. + /** + * Computes conservative whole-week activity totals when detailed path expansion reaches its + * budget. Uses the Monday reset to establish Sunday and the final event counters to solve + * six-day aggregates. Never assigns an aggregate possibility to a specific day. Returns minimum + * or exact aggregate values compatible with all end-event arithmetic. + */ private fun resolveFinalEventTotals( days: List, zoneId: ZoneId, @@ -590,20 +573,12 @@ internal object GunsmokeWeekSolver { return minimum.toInt().takeIf { minimum in 1L..ActivityInference.MAX_WEEKLY_ATTEMPTS.toLong() } } - // Function Name: initialCapturedStates - // Description: - // - Establishes every valid state at the first capture in a contiguous run. - // - Uses a direct boundary when available, otherwise derives Sunday from the reset score counter. - // - Preserves all attendance, patrol, and score-rounding possibilities until later counters prune them. - // Parameters: - // - runStart: First observed day index in the contiguous run. - // - anchor: Latest snapshot before the report period, if one exists. - // - checkpoint: First member observation in the run. - // - zoneId: Game-day timezone used to derive the preceding counter boundary. - // - periodStart: Sunday 05:00 report boundary. - // - cell: Conservatively derived first-day cell. - // Returns: - // - Returns every compatible captured activity state. + /** + * Establishes every valid state at the first capture in a contiguous run. Uses a direct + * boundary when available, otherwise derives Sunday from the reset score counter. Preserves all + * attendance, patrol, and score-rounding possibilities until later counters prune them. Returns + * every compatible captured activity state. + */ private fun initialCapturedStates( runStart: Int, anchor: Checkpoint?, @@ -651,17 +626,11 @@ internal object GunsmokeWeekSolver { }.distinct() } - // Function Name: isClosedOpeningAnchor - // Description: - // - 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. - // Parameters: - // - capturedAt: Latest counter observation before the Gunsmoke report boundary. - // - weeklyMerit: Weekly counter observed at capturedAt. - // - zoneId: Game-day timezone used for the calendar reset boundary. - // - periodStart: Sunday 05:00 Gunsmoke report boundary. - // Returns: - // - True when no unobserved pre-boundary merit can contaminate the opening interval. + /** + * 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. + */ internal fun isClosedOpeningAnchor( capturedAt: Instant, weeklyMerit: Long, @@ -720,19 +689,12 @@ internal object GunsmokeWeekSolver { ) } - // Function Name: statesExtending - // Description: - // - Enumerates previous-day final states through their complementary current-day states. - // - Avoids scanning every score up to the raw counter delta when only 28 remainders can exist. - // - Refuses pathological counter ranges so malformed data degrades to conservative cells. - // Parameters: - // - prefix: State already captured during the previous game day. - // - maximumMerit: Largest previous-day final merit allowed by the counter transition. - // - meritDelta: Total-merit counter change between the two captures. - // - scoreDelta: Total-score counter change between the two captures. - // - maximumAttemptScore: Latest captured high score used as a safe per-attempt ceiling. - // Returns: - // - Valid extensions, or null when the deterministic search budget would be exceeded. + /** + * Enumerates previous-day final states through their complementary current-day states. Avoids + * scanning every score up to the raw counter delta when only 28 remainders can exist. Refuses + * pathological counter ranges so malformed data degrades to conservative cells. Returns valid + * extensions, or null when the deterministic search budget would be exceeded. + */ private fun statesExtending( prefix: ActivityState, minimumMerit: Long, 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 3d05601..e65c2aa 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -221,16 +221,11 @@ class PlatoonBackupManager internal constructor( } } - // Function Name: replaceRestoredState - // Description: - // - Replaces the validated database and retires target-device roster CSV evidence atomically. - // - Replaces settings only for a complete backup while preserving them for a v1 backup. - // - Uses the durable restore journal so a process death or failure rolls every resource back. - // Parameters: - // - stagedDatabase: Validated database extracted from the selected backup. - // - restoredSettings: Complete-backup settings, or null for Platoon-only compatibility restore. - // Returns: - // - Returns normally after the restored state commits and rollback artifacts are cleaned. + /** + * Replaces the validated database and retires target-device roster CSV evidence atomically. + * Replaces settings only for a complete backup while preserving them for a v1 backup. Uses the + * durable restore journal so a process death or failure rolls every resource back. + */ private fun replaceRestoredState( stagedDatabase: File, restoredSettings: AppBackupSettings?, @@ -410,15 +405,10 @@ class PlatoonBackupManager internal constructor( } } - // Function Name: beginRestoreTransaction - // Description: - // - Journals only the resources that the selected backup format can replace. - // - Keeps v1 database-only restore independent from app settings validation. - // Parameters: - // - previousSettings: Settings rollback value for complete restore, or null for v1 restore. - // - databaseExisted: Whether rollback must restore a previous database or remove a new one. - // Returns: - // - Returns normally after the durable PREPARED transaction state is written. + /** + * Journals only the resources that the selected backup format can replace. Keeps v1 + * database-only restore independent from app settings validation. + */ private fun beginRestoreTransaction( previousSettings: AppBackupSettings?, databaseExisted: Boolean, @@ -811,11 +801,6 @@ enum class RestoredBackupKind { COMPLETE, } -// Class Name: InvalidBackupException -// Role: Identifies failures that prove the selected backup is invalid. -// Responsibilities: -// - Error classification: Separates backup validation failures from operational restore errors. -// Attributes: -// - cause: Validation exception raised while reading the selected backup. +/** A failure proving the selected backup invalid, as opposed to an operational restore error. */ internal class InvalidBackupException(cause: Exception) : Exception("Selected backup failed validation", cause) 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 c489006..d449eea 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonCsvImportStore.kt @@ -17,14 +17,11 @@ import java.time.format.DateTimeFormatter /** Validates and durably retains user-selected Platoon roster CSV evidence. */ class PlatoonCsvImportStore(private val directory: File) { - // Function Name: prepare - // Description: - // - 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. - // Parameters: - // - input: User-selected document stream owned by the caller. - // Returns: - // - An immutable prepared import containing only validated data and its stable identity. + /** + * 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 + * an immutable prepared import containing only validated data and its stable identity. + */ fun prepare(input: InputStream): PreparedImport { val bytes = readBounded(input) val text = StandardCharsets.UTF_8.newDecoder() @@ -46,14 +43,11 @@ class PlatoonCsvImportStore(private val directory: File) { fun retain(input: InputStream): RetainResult = retain(prepare(input)) - // Function Name: retain - // Description: - // - Publishes a previously validated import under its deterministic identity. - // - Uses a durable temporary write and rejects a content-hash identity collision. - // Parameters: - // - prepared: Validated content returned by prepare. - // Returns: - // - Stable retained file identity and whether identical bytes already existed. + /** + * Publishes a previously validated import under its deterministic identity. Uses a durable + * temporary write and rejects a content-hash identity collision. Returns stable retained file + * identity and whether identical bytes already existed. + */ fun retain(prepared: PreparedImport): RetainResult { val bytes = prepared.encoded val capturedAt = prepared.capturedAt diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyMetricPresentation.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyMetricPresentation.kt index f1536fe..9abc725 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyMetricPresentation.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyMetricPresentation.kt @@ -12,16 +12,11 @@ object WeeklyMetricPresentation { else -> "?" } - // Function Name: warnsBelowCutline - // Description: - // - Marks a displayed exact value or confirmed lower bound when it remains below its cutoff. - // - Leaves unknown values and lower bounds that already meet the cutoff unmarked. - // Parameters: - // - value: Numeric metric value rendered in the weekly table, or null when unavailable. - // - certainty: Whether the value is exact, a lower bound, or unknown. - // - belowCutline: Metric-specific cutoff comparison. - // Returns: - // - Returns true when the displayed value should use the cutoff warning color. + /** + * Marks a displayed exact value or confirmed lower bound when it remains below its cutoff. + * Leaves unknown values and lower bounds that already meet the cutoff unmarked. Returns true + * when the displayed value should use the cutoff warning color. + */ fun warnsBelowCutline( value: T?, certainty: MetricCertainty, 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 4b6eeea..886cc31 100644 --- a/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/GunsmokeWeekSolverTest.kt @@ -162,12 +162,10 @@ class GunsmokeWeekSolverTest { assertEquals(MetricCertainty.EXACT, cells[4].meritCertainty) } - // Function Name: restoredSevenDayHistoryCompletesWithinTheNavigationBudget - // Description: - // - Reproduces the seven counter checkpoints that previously made week navigation hang. - // - Guards the complete solver path as well as the exact three-attempt daily projections. - // Returns: - // - Completes within three seconds with seven valid projected cells. + /** + * Reproduces the seven counter checkpoints that previously made week navigation hang; the + * complete solver path must finish within three seconds with exact three-attempt days. + */ @Test(timeout = 3_000) fun restoredSevenDayHistoryCompletesWithinTheNavigationBudget() { val totals = listOf( @@ -270,12 +268,11 @@ class GunsmokeWeekSolverTest { assertNull(contradictory.totals) } - // Function Name: finalEventPacketCanConfirmWeeklyAttemptsWithoutFixingTheirDailyPlacement - // Description: - // - Models three adjacent days whose two-attempt shortfall can move between days. - // - Proves the final event packet still fixes the whole-week total at twenty attempts. - // Returns: - // - Keeps the affected daily cells as lower bounds while publishing an exact aggregate. + /** + * Three adjacent days share a two-attempt shortfall whose placement can move. The final + * event packet still fixes the weekly total at twenty attempts while those days stay lower + * bounds. + */ @Test fun finalEventPacketCanConfirmWeeklyAttemptsWithoutFixingTheirDailyPlacement() { val days = (0L..6L).map(start::plusDays) From c5dc25a38839358d48153007958f4e74f2d56175 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:02:49 +0900 Subject: [PATCH 069/125] fix: never merge a roster cut short by a parser boundary into a later roster --- .../app/capture/GuildMembersCsvWriter.kt | 118 ++++++------------ .../app/capture/PlatoonCaptureSession.kt | 2 +- .../app/capture/GuildMembersCsvWriterTest.kt | 96 ++++---------- 3 files changed, 63 insertions(+), 153 deletions(-) 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 d2dd7bd..08a4716 100644 --- a/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt +++ b/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt @@ -1,7 +1,6 @@ package dev.gf2log.app.capture import dev.gf2log.app.history.AtomicFiles -import dev.gf2log.protocol.Gfl2PayloadDecoder import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.LogTime import dev.gf2log.protocol.model.GuildMember @@ -14,58 +13,56 @@ import java.time.ZoneOffset import java.time.format.DateTimeFormatter /** - * Collects one logical roster dataset in memory and writes it once, deduplicated by UID, only after - * protocol completion. An empty `.partial` file reserves the final name while the batch is open. + * Writes each completed logical roster dataset once, deduplicated by UID. The stream parser + * already merges continuation frames, so a roster payload that does not complete its logical + * payload was cut short by another payload or by flow closure. Like the reference logger, such a + * boundary ends the dataset; it is discarded instead of being merged with a later, unrelated roster. + * An empty `.partial` file reserves the final name while the CSV is written. */ 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 = {}, -) : AutoCloseable { - private var activeBatch: Batch? = null - +) { @Synchronized fun accept(payload: ParsedPayload, flowEnded: Boolean = false): SaveResult? { - if (payload.payloadType != Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS) { - discardActiveBatch() - return null - } - val data = payload.data as? GuildMembersData ?: return null - if (!GuildMembersCsv.hasValidMemberBounds(data.members)) { - discardActiveBatch() - error("Platoon roster exceeds the member or name limit") - } - var batch = activeBatch - if (batch == null || (batch.previousMessageId != 0 && batch.previousMessageId != payload.messageId)) { - discardActiveBatch() - batch = openBatch() - activeBatch = batch - } - if ((batch.members.keys + data.members.map(GuildMember::uid)).size > - GuildMembersCsv.MAX_ROSTER_MEMBERS - ) { - discardActiveBatch() - error("Platoon roster exceeds the member limit") + check(GuildMembersCsv.hasValidMemberBounds(data.members)) { + "Platoon roster exceeds the member or name limit" } - - data.members.forEach { member -> batch.members[member.uid] = member } - batch.previousMessageId = payload.messageId - - if (payload.messageId != 0 && payload.isEndOfMessage) return publishActiveBatch() - if (flowEnded) discardActiveBatch() - return null + if (payload.messageId == 0 || !payload.isEndOfMessage) return null + val members = linkedMapOf() + data.members.forEach { member -> members[member.uid] = member } + if (members.isEmpty()) return null + return publish(members.values.toList()) } - @Synchronized - override fun close() = discardActiveBatch() + private fun publish(members: List): SaveResult { + val batch = reserve(Instant.now(clock), members) + try { + batch.workingFile.bufferedWriter(Charsets.UTF_8).use { writer -> + writer.appendLine(GuildMembersCsv.HEADER) + members.forEach { member -> + writer.appendLine(GuildMembersCsv.row(member, batch.completed.logTime)) + } + } + publishWithRetry(batch.workingFile, batch.completed.file) + } catch (error: Exception) { + // Structured ingestion still gets the completed batch; the caller sees the failure. + runCatching { onBatchClosed(batch.completed) } + .exceptionOrNull() + ?.let(error::addSuppressed) + throw error + } + onBatchClosed(batch.completed) + return SaveResult(batch.completed.file, members.size) + } - private fun openBatch(): Batch { + private fun reserve(instant: Instant, members: List): Reservation { check(outputDirectory.isDirectory || outputDirectory.mkdirs()) { "Unable to create the roster directory" } - val instant = Instant.now(clock) val filenameStem = "gf2log_platoonmembers_${FILE_TIME_FORMAT.format(instant)}" var suffix = 1 while (true) { @@ -73,48 +70,12 @@ class GuildMembersCsvWriter( val finalFile = File(outputDirectory, name) val workingFile = File(outputDirectory, "$name.partial") if (!finalFile.exists() && workingFile.createNewFile()) { - return Batch(workingFile, finalFile, LogTime.format(instant)) + return Reservation(workingFile, CompletedBatch(finalFile, LogTime.format(instant), members)) } suffix += 1 } } - private fun discardActiveBatch() { - activeBatch?.workingFile?.delete() - activeBatch = null - } - - private fun publishActiveBatch(): SaveResult? { - val batch = activeBatch ?: return null - activeBatch = null - if (batch.members.isEmpty()) { - batch.workingFile.delete() - return null - } - val completed = CompletedBatch( - file = batch.finalFile, - logTime = batch.logTime, - members = batch.members.values.toList(), - ) - try { - batch.workingFile.bufferedWriter(Charsets.UTF_8).use { writer -> - writer.appendLine(GuildMembersCsv.HEADER) - completed.members.forEach { member -> - writer.appendLine(GuildMembersCsv.row(member, completed.logTime)) - } - } - publishWithRetry(batch.workingFile, batch.finalFile) - } catch (error: Exception) { - // Structured ingestion still gets the completed batch; the caller sees the failure. - runCatching { onBatchClosed(completed) } - .exceptionOrNull() - ?.let(error::addSuppressed) - throw error - } - onBatchClosed(completed) - return SaveResult(completed.file, completed.members.size) - } - private fun publishWithRetry(workingFile: File, finalFile: File) { var firstFailure: Exception? = null repeat(PUBLISH_ATTEMPTS) { attempt -> @@ -142,14 +103,7 @@ class GuildMembersCsvWriter( val members: List, ) - private class Batch( - val workingFile: File, - val finalFile: File, - val logTime: String, - ) { - val members = linkedMapOf() - var previousMessageId: Int = -1 - } + private class Reservation(val workingFile: File, val completed: CompletedBatch) companion object { private const val PUBLISH_ATTEMPTS = 2 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 0859f5f..949b55e 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt @@ -28,7 +28,7 @@ internal class PlatoonCaptureSession( fun dispatch(payload: ParsedPayload, flowEnded: Boolean = false): PlatoonPayloadDispatcher.Results = dispatcher.dispatch(payload, flowEnded) - override fun close() = writer.close() + override fun close() = Unit private fun ingestCompletedRoster(batch: GuildMembersCsvWriter.CompletedBatch) { val directResult = runCatching { 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 76c09da..35eaa89 100644 --- a/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/GuildMembersCsvWriterTest.kt @@ -10,6 +10,7 @@ import java.time.Instant import java.time.ZoneOffset import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -20,14 +21,14 @@ class GuildMembersCsvWriterTest { val temporaryFolder = TemporaryFolder() @Test - fun continuationPayloadsShareOneCsvAndLogTime() { + fun completedRosterIsWrittenOnceWithItsLogTime() { val output = temporaryFolder.newFolder("guild-members") val clock = Clock.fixed(Instant.parse("2026-07-21T19:11:09.987Z"), ZoneOffset.UTC) val writer = GuildMembersCsvWriter(output, clock) - writer.accept(payload(messageId = 0, uid = 123456u, name = "First Member", end = true)) - writer.accept(payload(messageId = 42, uid = 654321u, name = "Second Member", end = true)) - writer.close() + writer.accept( + payload(42, end = true, member(123456u, "First Member"), member(654321u, "Second Member")), + ) val file = output.listFiles().orEmpty().single() assertEquals("gf2log_platoonmembers_20260721T191109Z.csv", file.name) @@ -41,6 +42,22 @@ class GuildMembersCsvWriterTest { ) } + @Test + fun rosterCutShortByAnotherPayloadIsNeverMergedWithALaterRoster() { + val output = temporaryFolder.newFolder("boundary") + val clock = Clock.fixed(Instant.parse("2026-07-21T19:11:09Z"), ZoneOffset.UTC) + val completed = mutableListOf() + val writer = GuildMembersCsvWriter(output, clock, onBatchClosed = completed::add) + + // The parser emits a message-id-0 roster only when a boundary ended it unterminated. + assertNull(writer.accept(payload(messageId = 0, uid = 1u, name = "Stale", end = true))) + assertTrue(output.listFiles().orEmpty().isEmpty()) + val saved = writer.accept(payload(messageId = 42, uid = 2u, name = "Current", end = true)) + + assertEquals(1, saved?.rowCount) + assertEquals(listOf("Current"), completed.single().members.map { it.name }) + } + @Test fun completedBatchesWithinOneSecondUseDistinctFiles() { val output = temporaryFolder.newFolder("same-second-batches") @@ -49,7 +66,6 @@ class GuildMembersCsvWriterTest { writer.accept(payload(messageId = 41, uid = 1u, name = "First", end = true)) writer.accept(payload(messageId = 42, uid = 2u, name = "Second", end = true)) - writer.close() assertEquals( listOf( @@ -60,32 +76,6 @@ class GuildMembersCsvWriterTest { ) } - @Test - fun flowCloseDiscardsMessageZeroContinuationWithoutProtocolCompletion() { - val output = temporaryFolder.newFolder("flow-ended-batches") - val clock = Clock.fixed(Instant.parse("2026-07-21T19:11:09Z"), ZoneOffset.UTC) - val completed = mutableListOf() - val writer = GuildMembersCsvWriter(output, clock, onBatchClosed = completed::add) - - writer.accept( - payload(messageId = 0, uid = 1u, name = "First", end = true), - flowEnded = true, - ) - writer.accept(payload(messageId = 42, uid = 2u, name = "Second", end = true)) - writer.close() - - val files = output.listFiles().orEmpty().sortedBy { it.name } - assertEquals(1, files.size) - assertEquals(1, completed.size) - assertEquals( - listOf( - GuildMembersCsv.HEADER, - "2,Second,60,120,4560,789,1234,1700000000,2026-07-21T19:11:09Z", - ), - files.single().readLines(Charsets.UTF_8), - ) - } - @Test fun completionCallbackReceivesTheWholeDeduplicatedBatch() { val output = temporaryFolder.newFolder("completed-batch") @@ -93,8 +83,7 @@ class GuildMembersCsvWriterTest { val completed = mutableListOf() val writer = GuildMembersCsvWriter(output, clock, onBatchClosed = completed::add) - writer.accept(payload(messageId = 0, uid = 1u, name = "Old", end = true)) - val saved = writer.accept(payload(messageId = 44, uid = 1u, name = "Current", end = true)) + val saved = writer.accept(payload(44, end = true, member(1u, "Old"), member(1u, "Current"))) assertEquals(1, saved?.rowCount) assertEquals(2, saved?.file?.readLines(Charsets.UTF_8)?.size) @@ -109,28 +98,6 @@ class GuildMembersCsvWriterTest { assertEquals("Current", parsed.members.single().name) } - @Test - fun incompleteBatchIsNotPublishedAsCsvUntilProtocolCompletion() { - val output = temporaryFolder.newFolder("publish-on-completion") - val clock = Clock.fixed(Instant.parse("2026-07-21T19:11:09Z"), ZoneOffset.UTC) - val writer = GuildMembersCsvWriter(output, clock) - - val partial = writer.accept(payload(messageId = 0, uid = 1u, name = "First", end = false)) - - assertEquals(null, partial) - assertTrue(output.listFiles().orEmpty().none { it.extension == "csv" }) - assertEquals(1, output.listFiles().orEmpty().count { it.extension == "partial" }) - - val completed = writer.accept( - payload(messageId = 42, uid = 2u, name = "Second", end = true), - ) - - assertEquals("gf2log_platoonmembers_20260721T191109Z.csv", completed?.file?.name) - assertEquals(2, completed?.rowCount) - assertTrue(output.listFiles().orEmpty().none { it.extension == "partial" }) - assertEquals(1, output.listFiles().orEmpty().count { it.extension == "csv" }) - } - @Test fun transientPublicationFailureRetriesImmediately() { val output = temporaryFolder.newFolder("publication-failure") @@ -199,20 +166,6 @@ class GuildMembersCsvWriterTest { assertEquals(1, output.listFiles().orEmpty().count { it.extension == "csv" }) } - @Test - fun shutdownDiscardsAnUnterminatedContinuation() { - val output = temporaryFolder.newFolder("incomplete-batch") - val clock = Clock.fixed(Instant.parse("2026-07-21T19:11:09Z"), ZoneOffset.UTC) - val completed = mutableListOf() - val writer = GuildMembersCsvWriter(output, clock, onBatchClosed = completed::add) - - writer.accept(payload(messageId = 0, uid = 1u, name = "Partial", end = false)) - writer.close() - - assertTrue(completed.isEmpty()) - assertTrue(output.listFiles().orEmpty().isEmpty()) - } - @Test fun completedEmptyPayloadDeletesHeaderOnlyCsv() { val output = temporaryFolder.newFolder("empty-completed-batch") @@ -284,11 +237,14 @@ class GuildMembersCsvWriterTest { } private fun payload(messageId: Int, uid: UInt, name: String, end: Boolean): ParsedPayload = + payload(messageId, end, member(uid, name)) + + private fun payload(messageId: Int, end: Boolean, vararg members: GuildMember): ParsedPayload = ParsedPayload( messageId = messageId, payloadType = Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS, isEndOfMessage = end, - data = GuildMembersData(listOf(member(uid, name))), + data = GuildMembersData(members.toList()), ) private fun member(uid: UInt, name: String) = GuildMember( From 72cf74d3d768a491dd7ab99ae4648832dfba2d0f Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:03:42 +0900 Subject: [PATCH 070/125] refactor: drop flow-end plumbing now that roster writes are stateless --- .../gf2log/app/capture/CaptureFlowEngine.kt | 30 ++++++++----------- .../app/capture/GuildMembersCsvWriter.kt | 2 +- .../capture/PlatoonAdmissionCoordinator.kt | 14 +++------ .../app/capture/PlatoonCaptureSession.kt | 9 ++---- .../app/capture/PlatoonPayloadDispatcher.kt | 23 ++++++-------- .../capture/PlatoonPayloadDispatcherTest.kt | 20 ++++++------- 6 files changed, 40 insertions(+), 58 deletions(-) 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 ac1c5ce..b1716b7 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureFlowEngine.kt @@ -55,7 +55,6 @@ internal class CaptureFlowEngine( /** Discards per-flow state after the native thread has been joined and its chunks drained. */ fun endSession() { admission.endSession() - sessions.values.forEach { runCatching(it::close) } sessions.clear() parsers.clear() metadata.clear() @@ -99,24 +98,21 @@ internal class CaptureFlowEngine( override fun isOpen(flowId: Long): Boolean = flowId in metadata || flowId in parsers - override fun bind(flowId: Long, profile: PlatoonProfile): PlatoonCaptureSession { - sessions.remove(flowId)?.close() - return PlatoonCaptureSession( + override fun bind(flowId: Long, profile: PlatoonProfile): PlatoonCaptureSession = + PlatoonCaptureSession( context = context, profile = profile, onRosterCaptured = { storageId -> tracker?.rosterCaptured(storageId) }, ).also { sessions[flowId] = it } - } override fun unbind(flowId: Long, session: PlatoonCaptureSession) { sessions.remove(flowId, session) - runCatching(session::close) } /** Permanently blocks management routing for this flow until native closure. */ override fun quarantine(flowId: Long) { taintedFlows += flowId - sessions.remove(flowId)?.close() + sessions.remove(flowId) preIdentity.reject(flowId) } @@ -124,10 +120,10 @@ internal class CaptureFlowEngine( observedProfiles.remove(storageId) } - override fun route(session: PlatoonCaptureSession, payload: ParsedPayload, flowEnded: Boolean) { + override fun route(session: PlatoonCaptureSession, payload: ParsedPayload) { val storageId = session.profile.storageId saveHistory(storageId, payload) - val routed = session.dispatch(payload, flowEnded) + val routed = session.dispatch(payload) routed.activity ?.onSuccess { if (it) tracker?.mark(storageId, Gfl2PayloadDecoder.TYPE_PLATOON_ACTIVITY) } ?.onFailure { CaptureStatus.report(CaptureNotice.ACTIVITY_SAVE_FAILED) } @@ -135,7 +131,7 @@ internal class CaptureFlowEngine( ?.onSuccess { if (it) tracker?.mark(storageId, Gfl2PayloadDecoder.TYPE_PLATOON_UPDATES) } ?.onFailure { CaptureStatus.report(CaptureNotice.UPDATES_SAVE_FAILED) } routed.members - .onSuccess { saved -> + ?.onSuccess { saved -> if (saved != null) { CaptureStatus.report( CaptureNotice.ROSTER_SAVED, @@ -144,7 +140,7 @@ internal class CaptureFlowEngine( ) } } - .onFailure { CaptureStatus.report(CaptureNotice.ROSTER_SAVE_FAILED) } + ?.onFailure { CaptureStatus.report(CaptureNotice.ROSTER_SAVE_FAILED) } if (payload.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) { tracker?.mark(storageId, Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE) } @@ -189,15 +185,15 @@ internal class CaptureFlowEngine( private fun close(flowId: Long) { val parser = parsers.remove(flowId) if (!taintedFlows.remove(flowId) && parser != null) { - process(flowId, parser.finish(), flowEnded = true) + process(flowId, parser.finish()) } metadata.remove(flowId) admission.flowClosed(flowId) preIdentity.remove(flowId) - sessions.remove(flowId)?.close() + sessions.remove(flowId) } - private fun process(flowId: Long, events: List, flowEnded: Boolean = false) { + private fun process(flowId: Long, events: List) { if (events.isEmpty()) return val payloads = mutableListOf() var warnings = 0 @@ -210,14 +206,14 @@ internal class CaptureFlowEngine( } stats.recordParsed(payloads.size, warnings) - payloads.forEachIndexed { index, payload -> + payloads.forEach { payload -> val profile = payload.data as? PlatoonProfileData if (profile != null && PlatoonProfilePolicy.isValid(profile)) identify(flowId, profile) val session = sessions[flowId] if (session == null) { holdUntilIdentified(flowId, payload) } else { - route(session, payload, flowEnded = flowEnded && index == payloads.lastIndex) + route(session, payload) } } } @@ -258,7 +254,7 @@ internal class CaptureFlowEngine( if (current != null) return val session = bind(flowId, profile) - preIdentity.take(flowId).forEach { pending -> route(session, pending, flowEnded = false) } + preIdentity.take(flowId).forEach { pending -> route(session, pending) } CaptureStatus.report( CaptureNotice.PLATOON_DETECTED, profile.platoonName.take(PlatoonAdmissionCoordinator.MAX_STATUS_NAME_CHARS), 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 08a4716..891434a 100644 --- a/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt +++ b/app/src/main/java/dev/gf2log/app/capture/GuildMembersCsvWriter.kt @@ -26,7 +26,7 @@ class GuildMembersCsvWriter( private val onBatchClosed: (CompletedBatch) -> Unit = {}, ) { @Synchronized - fun accept(payload: ParsedPayload, flowEnded: Boolean = false): SaveResult? { + fun accept(payload: ParsedPayload): SaveResult? { val data = payload.data as? GuildMembersData ?: return null check(GuildMembersCsv.hasValidMemberBounds(data.members)) { "Platoon roster exceeds the member or name limit" 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 7406036..2f69e16 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonAdmissionCoordinator.kt @@ -23,7 +23,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, flowEnded: Boolean) + fun route(session: PlatoonCaptureSession, payload: ParsedPayload) fun quarantine(flowId: Long) fun profileChanged(storageId: String) } @@ -150,16 +150,10 @@ internal class PlatoonAdmissionCoordinator( claim.flowIds.filterNot(claim.endedFlowIds::contains).forEach { flowId -> sessions[flowId] = flows.bind(flowId, profile) } - val lastPayloadIndexByFlow = claim.payloads - .withIndex() - .associate { it.value.flowId to it.index } - claim.payloads.forEachIndexed { index, buffered -> - val session = sessions.getOrPut(buffered.flowId) { flows.bind(buffered.flowId, profile) } + claim.payloads.forEach { buffered -> flows.route( - session = session, - payload = buffered.payload, - flowEnded = buffered.flowId in claim.endedFlowIds && - lastPayloadIndexByFlow[buffered.flowId] == index, + sessions.getOrPut(buffered.flowId) { flows.bind(buffered.flowId, profile) }, + buffered.payload, ) } } 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 949b55e..a24b833 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonCaptureSession.kt @@ -7,12 +7,12 @@ import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.protocol.model.ParsedPayload import java.time.Instant -/** Owns all mutable capture-to-management state for one identified TCP flow. */ +/** Routes one identified TCP flow's payloads into its verified Platoon scope. */ internal class PlatoonCaptureSession( context: Context, val profile: PlatoonProfile, private val onRosterCaptured: (String) -> Unit, -) : AutoCloseable { +) { private val scope = PlatoonStorageScope(profile.storageId) private val repository = PlatoonRepository(context, scope) private val writer = GuildMembersCsvWriter( @@ -25,10 +25,7 @@ internal class PlatoonCaptureSession( onUpdates = { repository.ingestUpdates(it).acceptedObservations > 0 }, ) - fun dispatch(payload: ParsedPayload, flowEnded: Boolean = false): PlatoonPayloadDispatcher.Results = - dispatcher.dispatch(payload, flowEnded) - - override fun close() = Unit + fun dispatch(payload: ParsedPayload): PlatoonPayloadDispatcher.Results = dispatcher.dispatch(payload) 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 152737f..be06913 100644 --- a/app/src/main/java/dev/gf2log/app/capture/PlatoonPayloadDispatcher.kt +++ b/app/src/main/java/dev/gf2log/app/capture/PlatoonPayloadDispatcher.kt @@ -1,29 +1,24 @@ package dev.gf2log.app.capture +import dev.gf2log.protocol.model.GuildMembersData import dev.gf2log.protocol.model.ParsedPayload import dev.gf2log.protocol.model.PlatoonActivityData import dev.gf2log.protocol.model.PlatoonUpdatesData +/** Routes each Platoon payload to its handler; one handler's failure never suppresses another. */ internal class PlatoonPayloadDispatcher( - private val onMembers: (ParsedPayload, Boolean) -> GuildMembersCsvWriter.SaveResult?, + private val onMembers: (ParsedPayload) -> GuildMembersCsvWriter.SaveResult?, private val onActivity: (PlatoonActivityData) -> Boolean, private val onUpdates: (PlatoonUpdatesData) -> Boolean, ) { - fun dispatch(payload: ParsedPayload, flowEnded: Boolean = false): Results { - val activity = (payload.data as? PlatoonActivityData)?.let { data -> - runCatching { onActivity(data) } - } - val updates = (payload.data as? PlatoonUpdatesData)?.let { data -> - runCatching { onUpdates(data) } - } - // Every decoded payload reaches the writer so a non-roster payload can - // close an unterminated roster continuation before later data arrives. - val members = runCatching { onMembers(payload, flowEnded) } - return Results(members, activity, updates) - } + 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) } }, + ) data class Results( - val members: Result, + val members: Result?, val activity: Result?, val updates: Result?, ) 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 34018ef..082a5f0 100644 --- a/app/src/test/java/dev/gf2log/app/capture/PlatoonPayloadDispatcherTest.kt +++ b/app/src/test/java/dev/gf2log/app/capture/PlatoonPayloadDispatcherTest.kt @@ -16,10 +16,10 @@ import org.junit.Test class PlatoonPayloadDispatcherTest { @Test - fun routesRequiredPayloadsAndAlwaysSignalsTheRosterWriter() { + fun routesEachRequiredPayloadOnlyToItsHandler() { val routed = mutableListOf() val dispatcher = PlatoonPayloadDispatcher( - onMembers = { _, _ -> routed += "members"; null }, + onMembers = { _ -> routed += "members"; null }, onActivity = { routed += "activity"; true }, onUpdates = { routed += "updates"; true }, ) @@ -29,32 +29,32 @@ class PlatoonPayloadDispatcherTest { val updates = dispatcher.dispatch(updatesPayload()) assertEquals( - listOf("members", "activity", "members", "updates", "members"), + listOf("members", "activity", "updates"), routed, ) - assertTrue(members.members.getOrThrow() == null) + assertTrue(members.members?.getOrThrow() == null) assertNull(members.activity) assertNull(members.updates) assertTrue(activity.activity?.getOrThrow() == true) - assertTrue(activity.members.getOrThrow() == null) + assertNull(activity.members) assertTrue(updates.updates?.getOrThrow() == true) - assertTrue(updates.members.getOrThrow() == null) + assertNull(updates.members) } @Test - fun handlerFailureDoesNotSuppressTheRosterBoundarySignal() { + fun handlerFailureIsReturnedInsteadOfThrown() { val routed = mutableListOf() val dispatcher = PlatoonPayloadDispatcher( - onMembers = { _, _ -> routed += "members"; null }, + onMembers = { _ -> routed += "members"; null }, onActivity = { routed += "activity"; error("database unavailable") }, onUpdates = { routed += "updates"; true }, ) val result = dispatcher.dispatch(activityPayload()) - assertEquals(listOf("activity", "members"), routed) + assertEquals(listOf("activity"), routed) assertTrue(result.activity?.isFailure == true) - assertTrue(result.members.isSuccess) + assertNull(result.members) assertNull(result.updates) } From 512293a3bf9f72f971fd0b67647cc12bd710aefe Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:03:56 +0900 Subject: [PATCH 071/125] refactor: split weekly report row and cell construction --- .../app/management/WeeklyReportBuilder.kt | 337 ++++++++++-------- 1 file changed, 186 insertions(+), 151 deletions(-) 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 f463ac2..3cc040f 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt @@ -313,22 +313,89 @@ object WeeklyReportBuilder { .filter { it.periodStart == start && it.gameDay in days } .associateBy { it.uid to it.gameDay } val sortedSnapshots = snapshots.sortedBy(PlatoonSnapshot::capturedAt) - val patrolFactsByMemberDay = dailyPatrolFacts - .filter { PlatoonPeriods.gameDay(it.occurredAt, zoneId) in days } - .groupBy { it.uid to PlatoonPeriods.gameDay(it.occurredAt, zoneId) } - .mapValues { (_, facts) -> facts.minBy(DailyPatrolFact::occurredAt) } - val loginFactsByMemberDay = sortedSnapshots - .flatMap { snapshot -> - snapshot.members.mapNotNull { member -> - member.lastLogin - .takeIf { it > 0L } - ?.let(Instant::ofEpochSecond) - ?.let { member.uid to PlatoonPeriods.gameDay(it, zoneId) } - } - } - .filter { it.second in days } - .toSet() + val week = WeekInputs( + days = days, + zoneId = zoneId, + isGunsmoke = isGunsmoke, + snapshots = sortedSnapshots, + overridesByCell = overridesByCell, + dailyPatrolFacts = dailyPatrolFacts, + patrolFactsByMemberDay = dailyPatrolFacts + .filter { PlatoonPeriods.gameDay(it.occurredAt, zoneId) in days } + .groupBy { it.uid to PlatoonPeriods.gameDay(it.occurredAt, zoneId) } + .mapValues { (_, facts) -> facts.minBy(DailyPatrolFact::occurredAt) }, + loginFactsByMemberDay = loginFactsByMemberDay(sortedSnapshots, days, zoneId), + asOf = asOf, + ) val rosterCutoff = rosterCutoff(start, zoneId, asOf) + val identities = memberIdentities( + sortedSnapshots, + membershipEvents, + overridesByCell.keys.map { it.first }, + rosterCutoff, + ) + // Weekly rows represent the roster that is active at the end of the selected reporting + // period (or the latest roster for an in-progress week). A member observed earlier in + // the week disappears as soon as a later complete roster records their withdrawal; the + // immutable membership event remains in the Join / Withdraw section. Rows are solved + // only for that roster because each row depends on its own member's evidence alone. + val rosterAtPeriodEnd = rosterAtCutoff(sortedSnapshots, membershipEvents, rosterCutoff) + + val rows = identities.values + .filter { it.uid in rosterAtPeriodEnd } + .map { latestKnown -> buildRow(latestKnown, week) } + .sortedWith( + if (isGunsmoke) { + compareByDescending { it.totalScore } + .thenByDescending { it.totalMerit } + .thenBy { it.name } + } else { + compareByDescending { it.totalMerit }.thenBy { it.name } + }, + ) + + return Report(start, start.plusDays(6), isGunsmoke, days, rows) + } + + /** Everything one member row is derived from; shared by every row of the week. */ + private class WeekInputs( + val days: List, + val zoneId: ZoneId, + val isGunsmoke: Boolean, + val snapshots: List, + val overridesByCell: Map, WeeklyCellOverride>, + val dailyPatrolFacts: List, + val patrolFactsByMemberDay: Map, DailyPatrolFact>, + val loginFactsByMemberDay: Set>, + val asOf: Instant, + ) + + private fun loginFactsByMemberDay( + sortedSnapshots: List, + days: List, + zoneId: ZoneId, + ): Set> = sortedSnapshots + .flatMap { snapshot -> + snapshot.members.mapNotNull { member -> + member.lastLogin + .takeIf { it > 0L } + ?.let(Instant::ofEpochSecond) + ?.let { member.uid to PlatoonPeriods.gameDay(it, zoneId) } + } + } + .filter { it.second in days } + .toSet() + + /** + * The latest known identity of every member the week can show: roster observations, newer + * exact Updates names or joins before [rosterCutoff], and placeholders for manual overrides. + */ + private fun memberIdentities( + sortedSnapshots: List, + membershipEvents: List, + overriddenUids: List, + rosterCutoff: Instant, + ): Map { val uids = sortedSnapshots.flatMap { it.members }.associateBy(SnapshotMember::uid).toMutableMap() val latestSnapshotAtByUid = buildMap { sortedSnapshots.forEach { snapshot -> @@ -365,104 +432,89 @@ object WeeklyReportBuilder { ) } } - overridesByCell.keys.map { it.first }.forEach { uid -> + overriddenUids.forEach { uid -> if (uid !in uids) { uids[uid] = SnapshotMember(uid, "#$uid", 0, 0, 0, 0, 0, 0) } } - // Weekly rows represent the roster that is active at the end of the selected reporting - // period (or the latest roster for an in-progress week). A member observed earlier in - // the week disappears as soon as a later complete roster records their withdrawal; the - // immutable membership event remains in the Join / Withdraw section. Rows are solved - // only for that roster because each row depends on its own member's evidence alone. - val rosterAtPeriodEnd = rosterAtCutoff(sortedSnapshots, membershipEvents, rosterCutoff) + return uids + } - val rows = uids.values.filter { it.uid in rosterAtPeriodEnd }.map { latestKnown -> - val derivedCells = days.map { day -> - buildCell( - uid = latestKnown.uid, - day = day, - zoneId = zoneId, - snapshots = sortedSnapshots, - isGunsmoke = isGunsmoke, - manualOverride = overridesByCell[latestKnown.uid to day], - dailyPatrolFact = patrolFactsByMemberDay[latestKnown.uid to day], - hasLoginFact = latestKnown.uid to day in loginFactsByMemberDay, - ) - } - val gunsmokeResolution = if (isGunsmoke) { - GunsmokeWeekSolver.resolve( - uid = latestKnown.uid, - days = days, - zoneId = zoneId, - snapshots = sortedSnapshots, - cells = derivedCells, - dailyPatrolFacts = dailyPatrolFacts, - ) - } else { - null - } - val standardResolution = if (!isGunsmoke) { - StandardWeekSolver.resolve( - uid = latestKnown.uid, - days = days, - zoneId = zoneId, - snapshots = sortedSnapshots, - cells = derivedCells, - asOf = asOf, - ) - } else { - null - } - val cells = gunsmokeResolution?.cells ?: standardResolution?.cells ?: derivedCells - val snapshotsInPeriod = sortedSnapshots.mapNotNull { snapshot -> - val gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, zoneId) - snapshot.member(latestKnown.uid) - ?.takeIf { gameDay in days } - ?.let { gameDay to it } - } - val derivedMeritTotal = cells.sumOf { it.meritDelta ?: 0L } - val sundayMerit = cells.firstOrNull()?.meritDelta ?: 0L - val mondayThroughSaturday = snapshotsInPeriod - .lastOrNull { (gameDay, _) -> gameDay.dayOfWeek.value in 1..6 } - ?.second - ?.weeklyMerit - ?: 0L - val derivedScoreTotal = cells.sumOf { it.scoreDelta ?: 0L } - val latestCapturedScore = snapshotsInPeriod.lastOrNull()?.second?.totalScore ?: 0L - MemberRow( - uid = latestKnown.uid, - name = latestKnown.name, - days = cells, - totalMerit = maxOf( - derivedMeritTotal, - sundayMerit + mondayThroughSaturday, - gunsmokeResolution?.totals?.merit ?: 0L, - standardResolution?.totals?.merit ?: 0L, - ), - totalScore = if (isGunsmoke) { - maxOf(derivedScoreTotal, latestCapturedScore) - } else { - derivedScoreTotal - }, - isGunsmokeWeek = isGunsmoke, - hasFinalGunsmokeScore = cells.any(DayCell::hasFinalGunsmokeScore), - resolvedGunsmokeTotals = gunsmokeResolution?.totals, - resolvedStandardTotals = standardResolution?.totals, - gunsmokeAttemptsFloor = gunsmokeResolution?.attemptsFloor, + private fun buildRow(latestKnown: SnapshotMember, week: WeekInputs): MemberRow { + val uid = latestKnown.uid + val derivedCells = week.days.map { day -> + buildCell( + uid = uid, + day = day, + zoneId = week.zoneId, + snapshots = week.snapshots, + isGunsmoke = week.isGunsmoke, + manualOverride = week.overridesByCell[uid to day], + dailyPatrolFact = week.patrolFactsByMemberDay[uid to day], + hasLoginFact = uid to day in week.loginFactsByMemberDay, ) } - .sortedWith( - if (isGunsmoke) { - compareByDescending { it.totalScore } - .thenByDescending { it.totalMerit } - .thenBy { it.name } - } else { - compareByDescending { it.totalMerit }.thenBy { it.name } - }, + val gunsmokeResolution = if (week.isGunsmoke) { + GunsmokeWeekSolver.resolve( + uid = uid, + days = week.days, + zoneId = week.zoneId, + snapshots = week.snapshots, + cells = derivedCells, + dailyPatrolFacts = week.dailyPatrolFacts, ) - - return Report(start, start.plusDays(6), isGunsmoke, days, rows) + } else { + null + } + val standardResolution = if (!week.isGunsmoke) { + StandardWeekSolver.resolve( + uid = uid, + days = week.days, + zoneId = week.zoneId, + snapshots = week.snapshots, + cells = derivedCells, + asOf = week.asOf, + ) + } else { + null + } + val cells = gunsmokeResolution?.cells ?: standardResolution?.cells ?: derivedCells + val snapshotsInPeriod = week.snapshots.mapNotNull { snapshot -> + val gameDay = PlatoonPeriods.gameDay(snapshot.capturedAt, week.zoneId) + snapshot.member(uid) + ?.takeIf { gameDay in week.days } + ?.let { gameDay to it } + } + val derivedMeritTotal = cells.sumOf { it.meritDelta ?: 0L } + val sundayMerit = cells.firstOrNull()?.meritDelta ?: 0L + val mondayThroughSaturday = snapshotsInPeriod + .lastOrNull { (gameDay, _) -> gameDay.dayOfWeek.value in 1..6 } + ?.second + ?.weeklyMerit + ?: 0L + val derivedScoreTotal = cells.sumOf { it.scoreDelta ?: 0L } + val latestCapturedScore = snapshotsInPeriod.lastOrNull()?.second?.totalScore ?: 0L + return MemberRow( + uid = uid, + name = latestKnown.name, + days = cells, + totalMerit = maxOf( + derivedMeritTotal, + sundayMerit + mondayThroughSaturday, + gunsmokeResolution?.totals?.merit ?: 0L, + standardResolution?.totals?.merit ?: 0L, + ), + totalScore = if (week.isGunsmoke) { + maxOf(derivedScoreTotal, latestCapturedScore) + } else { + derivedScoreTotal + }, + isGunsmokeWeek = week.isGunsmoke, + hasFinalGunsmokeScore = cells.any(DayCell::hasFinalGunsmokeScore), + resolvedGunsmokeTotals = gunsmokeResolution?.totals, + resolvedStandardTotals = standardResolution?.totals, + gunsmokeAttemptsFloor = gunsmokeResolution?.attemptsFloor, + ) } /** UIDs whose rows the report for [referenceDay] contains, without solving any row. */ @@ -518,19 +570,11 @@ object WeeklyReportBuilder { dailyPatrolFact = dailyPatrolFact, hasLoginFact = hasLoginFact, ) - val merit = manualOverride.meritDelta ?: derived.meritDelta - val score = manualOverride.scoreDelta ?: derived.scoreDelta - return derived.copy( - meritDelta = merit, - scoreDelta = score, - inference = if (merit != null && score != null) { - ActivityInference.infer(merit, score, isGunsmoke) - } else { - derived.inference - }, - manualOverride = manualOverride, - ) + return overlayManualOverride(derived, manualOverride, isGunsmoke) } + fun withActivityFacts(cell: DayCell) = + applyActivityFacts(cell, isGunsmoke, dailyPatrolFact, hasLoginFact) + val start = PlatoonPeriods.periodStartInstant(day, zoneId) val end = PlatoonPeriods.periodStartInstant(day.plusDays(1), zoneId) val observations = snapshots.filter { @@ -550,7 +594,7 @@ object WeeklyReportBuilder { } == true val hasClosingBoundary = lastObservation?.first?.capturedAt == end if (baselineSnapshot == null) { - return applyActivityFacts( + return withActivityFacts( DayCell( gameDay = day, meritDelta = null, @@ -565,13 +609,10 @@ object WeeklyReportBuilder { isGunsmokeWeek = isGunsmoke, hasClosingBoundary = hasClosingBoundary, ), - isGunsmoke, - dailyPatrolFact, - hasLoginFact, ) } - if (observations.isEmpty()) { - return applyActivityFacts( + if (observations.isEmpty() || (after == null && before == null)) { + return withActivityFacts( DayCell( day, null, @@ -580,24 +621,6 @@ object WeeklyReportBuilder { DailyEvidence.NO_OBSERVATION, isGunsmokeWeek = isGunsmoke, ), - isGunsmoke, - dailyPatrolFact, - hasLoginFact, - ) - } - if (after == null && before == null) { - return applyActivityFacts( - DayCell( - day, - null, - null, - null, - DailyEvidence.NO_OBSERVATION, - isGunsmokeWeek = isGunsmoke, - ), - isGunsmoke, - dailyPatrolFact, - hasLoginFact, ) } if (isGunsmoke && baselineSnapshot.capturedAt != start) { @@ -605,7 +628,7 @@ object WeeklyReportBuilder { // Its counter delta is an interval total, not a safe lower bound // for the new Gunsmoke day. The path solver may still reconcile it // with adjacent checkpoints; otherwise keep the daily metrics unknown. - return applyActivityFacts( + return withActivityFacts( DayCell( gameDay = day, meritDelta = null, @@ -617,9 +640,6 @@ object WeeklyReportBuilder { metricObservedAt = lastObservation?.first?.capturedAt, hasClosingBoundary = hasClosingBoundary, ), - isGunsmoke, - dailyPatrolFact, - hasLoginFact, ) } @@ -641,7 +661,7 @@ object WeeklyReportBuilder { 0L } } - return applyActivityFacts( + return withActivityFacts( DayCell( gameDay = day, meritDelta = meritDelta, @@ -656,9 +676,26 @@ object WeeklyReportBuilder { metricObservedAt = lastObservation?.first?.capturedAt, hasClosingBoundary = hasClosingBoundary, ), - isGunsmoke, - dailyPatrolFact, - hasLoginFact, + ) + } + + /** Overlays only the explicitly edited fields; untouched values keep their derivation. */ + private fun overlayManualOverride( + derived: DayCell, + manualOverride: WeeklyCellOverride, + isGunsmoke: Boolean, + ): DayCell { + val merit = manualOverride.meritDelta ?: derived.meritDelta + val score = manualOverride.scoreDelta ?: derived.scoreDelta + return derived.copy( + meritDelta = merit, + scoreDelta = score, + inference = if (merit != null && score != null) { + ActivityInference.infer(merit, score, isGunsmoke) + } else { + derived.inference + }, + manualOverride = manualOverride, ) } @@ -741,8 +778,6 @@ object WeeklyReportBuilder { else -> MetricCertainty.LOWER_BOUND } - - private const val BOUNDARY_WINDOW_MINUTES = 15L private const val MAX_STANDARD_WEEKLY_MERIT = ActivityInference.LOGIN_AND_PATROL_MERIT * 7 private const val STANDARD_LOGIN_MERIT = ActivityInference.LOGIN_MERIT From 97cc6347cd240bb57be85407f7c769a326e92c7f Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:03:32 +0900 Subject: [PATCH 072/125] feat: edit a member's week in one pass and trim Home dead ends - Weekly edit dialog adds Apply, next day; clearing manual values moves into the dialog body - Home shows Undo last CSV import only while a checkpoint exists and refreshes after imports - System back steps through onboarding pages --- .../main/java/dev/gf2log/app/MainActivity.kt | 21 ++++++++++- .../java/dev/gf2log/app/OnboardingActivity.kt | 20 +++++++---- .../main/java/dev/gf2log/app/WeeklyDialogs.kt | 36 ++++++++++--------- .../dev/gf2log/app/WeeklyReportActivity.kt | 4 ++- app/src/main/java/dev/gf2log/app/ui/Views.kt | 9 +++++ app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 68 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 08b90b3..3c7d525 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -26,6 +26,7 @@ import dev.gf2log.app.capture.CaptureVpnService import dev.gf2log.app.capture.format import dev.gf2log.app.history.CaptureHistoryStore import dev.gf2log.app.history.SavedHistoryStore +import dev.gf2log.app.management.CsvImportCheckpointManager import dev.gf2log.app.management.PlatoonCsvImportStore import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotComparison @@ -51,6 +52,7 @@ import dev.gf2log.app.ui.group import dev.gf2log.app.ui.icon import dev.gf2log.app.ui.lp import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.setShownInGroup import dev.gf2log.app.ui.panel import dev.gf2log.app.ui.row import dev.gf2log.app.ui.sectionHeader @@ -80,6 +82,7 @@ class MainActivity : BaseActivity() { private lateinit var checklist: LinearLayout private lateinit var captureActions: LinearLayout private lateinit var clients: LinearLayout + private lateinit var undoRow: View private lateinit var progressBanner: LinearLayout private lateinit var progressText: TextView private lateinit var historySection: LinearLayout @@ -181,6 +184,7 @@ class MainActivity : BaseActivity() { if (::clients.isInitialized) renderClients() loadHistory() loadRosterSummary() + loadUndoAvailability() csvImport.onScreenReady() } @@ -220,7 +224,7 @@ class MainActivity : BaseActivity() { }, navigationRow(getString(R.string.undo_last_csv_import), getString(R.string.undo_last_csv_import_detail), R.drawable.ic_undo) { csvImport.undo() - }, + }.also { undoRow = it }, navigationRow( getString(R.string.export_latest_platoon_csv), getString(R.string.export_latest_platoon_csv_detail), @@ -232,6 +236,7 @@ class MainActivity : BaseActivity() { )), lp()) historySection = column() addView(historySection, lp()) + undoRow.setShownInGroup(false) } // region Capture @@ -451,6 +456,19 @@ class MainActivity : BaseActivity() { private fun showProgress(message: CharSequence?) { progressBanner.visibility = if (message == null) View.GONE else View.VISIBLE progressText.text = message + if (message == null) { + // An import or undo just finished: the roster and the undo checkpoint may have changed. + loadRosterSummary() + loadUndoAvailability() + } + } + + /** The undo row only appears while a CSV import checkpoint exists. */ + private fun loadUndoAvailability() { + val scope = profileBinding.scope ?: return + work.run(key = UNDO, task = { CsvImportCheckpointManager(this, scope).canUndo() }) { result -> + undoRow.setShownInGroup(result.getOrDefault(false)) + } } private fun exportLatestRoster() { @@ -740,6 +758,7 @@ class MainActivity : BaseActivity() { private companion object { const val HISTORY = "history" const val SUMMARY = "summary" + const val UNDO = "undo" const val RECENT = "recent" const val SAVED = "saved" const val COLLAPSED_COUNT = 20 diff --git a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt index bd109c2..2a91831 100644 --- a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt @@ -52,6 +52,18 @@ class OnboardingActivity : BaseActivity() { } setContentView(root) renderPage(animate = false) + // System back steps through the pages before leaving onboarding. + onBackPressedDispatcher.addCallback(this, backToPreviousPage) + } + + private val backToPreviousPage = object : androidx.activity.OnBackPressedCallback(false) { + override fun handleOnBackPressed() = previousPage() + } + + private fun previousPage() { + if (pageIndex == 0) return + pageIndex-- + renderPage(animate = true, direction = -1) } override fun onSaveInstanceState(outState: Bundle) { @@ -100,12 +112,7 @@ class OnboardingActivity : BaseActivity() { addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL - backButton = button(getString(R.string.onboarding_back), Controls.Role.SECONDARY) { - if (pageIndex > 0) { - pageIndex-- - renderPage(animate = true, direction = -1) - } - } + backButton = button(getString(R.string.onboarding_back), Controls.Role.SECONDARY) { previousPage() } addView(backButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply { marginEnd = dp(6) }) @@ -147,6 +154,7 @@ class OnboardingActivity : BaseActivity() { stepLabel.text = getString(R.string.onboarding_step, pageIndex + 1, PAGES.size) stepLabel.contentDescription = stepLabel.text backButton.isEnabled = pageIndex > 0 + backToPreviousPage.isEnabled = pageIndex > 0 nextButton.text = getString( if (pageIndex == PAGES.lastIndex) R.string.onboarding_finish else R.string.onboarding_next, ) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt index 8a98cae..34bb0d5 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -18,6 +18,7 @@ import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.LabeledField import dev.gf2log.app.ui.TextRole import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.button import dev.gf2log.app.ui.badge import dev.gf2log.app.ui.column import dev.gf2log.app.ui.divider @@ -80,6 +81,7 @@ internal object WeeklyDialogs { uid: Long, cell: WeeklyReportBuilder.DayCell, current: WeeklyOverrideEditing.CellEdit?, + next: (() -> Unit)? = null, onDone: (WeeklyOverrideEditing.CellEdit) -> Unit, ) { fun numberField(label: Int, initial: String, hint: Int): Pair { @@ -106,6 +108,7 @@ internal object WeeklyDialogs { accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE visibility = View.GONE } + val clear = activity.button(activity.getString(R.string.clear_manual_values), Controls.Role.DESTRUCTIVE_TEXT) {} val content = Dialogs.content(activity) { addView(activity.text(activity.getString(R.string.manual_edit_hint), TextRole.SECONDARY), activity.lp()) addView(merit, activity.lp(top = 12)) @@ -118,17 +121,19 @@ internal object WeeklyDialogs { addView(choiceRow(activity, R.string.login_short, loginInitial) { login = it }, activity.lp(top = 12)) addView(choiceRow(activity, R.string.patrol_short, patrolInitial) { patrol = it }, activity.lp(top = 12)) addView(error, activity.lp(top = 8)) + if (cell.manualOverride != null) addView(clear, activity.lp(WRAP, WRAP, top = 4)) } + // The neutral slot walks the member's week so a whole row can be corrected in one pass. val builder = Dialogs.builder(activity) .setTitle(title) .setView(content) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.apply, null) - if (cell.manualOverride != null) builder.setNeutralButton(R.string.clear_manual_values, null) + 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 } } - fun finish(edit: WeeklyOverrideEditing.CellEdit) { + fun finish(edit: WeeklyOverrideEditing.CellEdit, then: (() -> Unit)? = null) { val probe = WeeklyOverrideEditing.merge( cell.manualOverride?.periodStart ?: cell.gameDay, isGunsmokeWeek, @@ -144,21 +149,20 @@ internal object WeeklyDialogs { } dialog.dismiss() onDone(edit) + then?.invoke() } - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - finish( - WeeklyOverrideEditing.CellEdit( - uid = uid, - gameDay = cell.gameDay, - merit = changed(merit to meritInitial) ?: current?.merit, - score = changed(score) ?: current?.score, - attempts = changed(attempts) ?: current?.attempts, - attended = login.takeIf { it != loginInitial } ?: current?.attended, - dailyPatrol = patrol.takeIf { it != patrolInitial } ?: current?.dailyPatrol, - ), - ) - } - dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { + fun edited() = WeeklyOverrideEditing.CellEdit( + uid = uid, + gameDay = cell.gameDay, + merit = changed(merit to meritInitial) ?: current?.merit, + score = changed(score) ?: current?.score, + attempts = changed(attempts) ?: current?.attempts, + attended = login.takeIf { it != loginInitial } ?: current?.attended, + dailyPatrol = patrol.takeIf { it != patrolInitial } ?: current?.dailyPatrol, + ) + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { finish(edited()) } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { finish(edited(), next) } + clear.setOnClickListener { finish( WeeklyOverrideEditing.CellEdit( uid = uid, diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index d0dbaf1..99e0c80 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -668,7 +668,9 @@ class WeeklyReportActivity : BaseActivity() { return } val key = member.uid to cell.gameDay - WeeklyDialogs.showEdit(this, title, current.report.isGunsmokeWeek, member.uid, cell, edits[key]) { edit -> + val nextCell = member.days.getOrNull(member.days.indexOf(cell) + 1) + val next = nextCell?.let { { onCellTapped(member, it) } } + WeeklyDialogs.showEdit(this, title, current.report.isGunsmokeWeek, member.uid, cell, edits[key], next) { edit -> if (edit.isEmpty) edits.remove(key) else edits[key] = edit rowsAdapter.notifyItemChanged(current.displayedMembers.indexOfFirst { it.uid == member.uid }) updateTopBar() 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 b2d634c..9d5f742 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Views.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -370,6 +370,15 @@ internal fun Context.group(rows: List): LinearLayout = column { } } +/** Shows or hides a row added through [group] together with the divider above it. */ +internal fun View.setShownInGroup(shown: Boolean) { + val visibility = if (shown) View.VISIBLE else View.GONE + this.visibility = visibility + val parent = parent as? ViewGroup ?: return + val index = parent.indexOfChild(this) + if (index > 0) parent.getChildAt(index - 1).visibility = visibility +} + /** A tappable grouped row with an optional icon, supporting text, and trailing value. */ internal fun Context.navigationRow( title: CharSequence, diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c873d8f..9d24848 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -589,4 +589,5 @@ 서클을 저장하는 중… 검색어 지우기 직접 옮긴 순서를 포함해 현재 순서를 모두 바꿉니다. + 적용 후 다음 날 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de867e2..94cdf31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -597,4 +597,5 @@ Saving this Platoon… Clear search This replaces the current order, including any members you moved by hand. + Apply, next day From bb792ea643039429223b3ba6ac20457b9f37171a Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:04:49 +0900 Subject: [PATCH 073/125] style: quieter ranks, visible cutline cells and a steadier type scale - Gunsmoke rank chips keep the accent for the top three only - Cells below a cutline get the warning fill; weekly cells recolor one reusable fill - Top bar title 20sp, navigation labels 12sp, onboarding title 26sp --- .../java/dev/gf2log/app/OnboardingActivity.kt | 2 +- .../java/dev/gf2log/app/PrimaryNavigation.kt | 2 +- .../main/java/dev/gf2log/app/WeeklyTable.kt | 31 ++++++++----------- app/src/main/java/dev/gf2log/app/ui/TopBar.kt | 2 +- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt index 2a91831..0a05783 100644 --- a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt @@ -197,7 +197,7 @@ class OnboardingActivity : BaseActivity() { addView(TextView(context).apply { text = if (pageIndex == 0) getString(R.string.app_name) else getString(page.title) gravity = Gravity.CENTER - textSize = 28f + textSize = 26f setTypeface(typeface, Typeface.BOLD) }, matchWidth()) addView(TextView(context).apply { diff --git a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt index 1b03485..d3fe07f 100644 --- a/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt +++ b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt @@ -96,7 +96,7 @@ internal object PrimaryNavigation { }, LinearLayout.LayoutParams(activity.dp(56), activity.dp(32))) addView(TextView(activity).apply { text = activity.getString(label) - textSize = 11f + textSize = 12f setTextColor(color) typeface = Typeface.create( "sans-serif-medium", diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt index 3e0d5a3..b6b0a2a 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt @@ -150,20 +150,17 @@ internal class WeeklyTable( ellipsize = TextUtils.TruncateAt.END } private val detail = context.text(null, TextRole.CAPTION) { maxLines = 1 } + private val rankChip = GradientDrawable().apply { cornerRadius = context.dpf(8f) } private val rank = context.text(null, TextRole.LABEL) { gravity = Gravity.CENTER - setTextColor(context.getColor(R.color.accent_text)) - background = GradientDrawable().apply { - setColor(context.getColor(R.color.accent_surface)) - cornerRadius = context.dpf(8f) - } + background = rankChip minWidth = context.dp(24) setPadding(context.dp(4), 0, context.dp(4), 0) visibility = if (isGunsmokeWeek) View.VISIBLE else View.GONE } private val frozen = context.row { setPadding(context.dp(10), 0, context.dp(6), 0) - background = cellBackground(R.color.table_cell, clickable = false) + background = ColorDrawable(context.getColor(R.color.table_cell)) addView(rank, context.lp(WRAP, WRAP, end = 6)) addView(context.column { addView(name, context.lp()) @@ -182,6 +179,10 @@ internal class WeeklyTable( name.text = member.name detail.text = "#${member.uid}" rank.text = rankValue?.toString().orEmpty() + // Only the podium carries the accent; the rest of the ranking stays quiet. + val podium = rankValue != null && rankValue <= PODIUM + rankChip.setColor(context.getColor(if (podium) R.color.accent_surface else R.color.surface_variant)) + rank.setTextColor(context.getColor(if (podium) R.color.accent_text else R.color.text_secondary)) frozen.contentDescription = listOfNotNull( rankValue?.let { context.getString(R.string.rank_description, it) }, member.name, @@ -202,6 +203,7 @@ internal class WeeklyTable( } private inner class MetricCell(widthDp: Int, private val total: Boolean) { + private val fill = ColorDrawable() private val merit = context.text(null, TextRole.SUBTITLE) { textSize = 16f gravity = Gravity.CENTER @@ -223,6 +225,7 @@ internal class WeeklyTable( addView(gunsmoke, context.lp()) addView(activity, context.lp()) layoutParams = LinearLayout.LayoutParams(width(widthDp), rowHeight) + background = RippleDrawable(ColorStateList.valueOf(context.getColor(R.color.control_ripple)), fill, null) isClickable = true isFocusable = true } @@ -252,13 +255,14 @@ internal class WeeklyTable( append(" ") appendMark(context.getString(R.string.patrol_initial), display.patrol) } - view.background = cellBackground( + // Recolor the cell's own fill in place; binding allocates no drawables. + fill.color = context.getColor( when { display.edited -> R.color.accent_surface + display.meritWarn || display.scoreWarn || display.attemptsWarn -> R.color.warning_surface total -> R.color.table_header else -> R.color.table_cell }, - clickable = true, ) view.contentDescription = buildString { append(description) @@ -416,16 +420,6 @@ internal class WeeklyTable( } } - private fun cellBackground(@ColorRes color: Int, clickable: Boolean) = if (clickable) { - RippleDrawable( - ColorStateList.valueOf(context.getColor(R.color.control_ripple)), - ColorDrawable(context.getColor(color)), - null, - ) - } else { - ColorDrawable(context.getColor(color)) - } - companion object { const val DAYS = 7 const val MEMBER_WIDTH = 124 @@ -434,6 +428,7 @@ internal class WeeklyTable( const val HEADER_HEIGHT = 48 const val STANDARD_ROW_HEIGHT = 64 const val GUNSMOKE_ROW_HEIGHT = 80 + const val PODIUM = 3 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/main/java/dev/gf2log/app/ui/TopBar.kt b/app/src/main/java/dev/gf2log/app/ui/TopBar.kt index 7a6a88a..e3e30dc 100644 --- a/app/src/main/java/dev/gf2log/app/ui/TopBar.kt +++ b/app/src/main/java/dev/gf2log/app/ui/TopBar.kt @@ -33,7 +33,7 @@ internal class TopBar(context: Context) : LinearLayout(context) { private val navigation = context.iconButton(R.drawable.ic_arrow_back, context.getString(R.string.back)) {} private val leading = context.frame() private val titleView = context.text(null, TextRole.TITLE) { - textSize = 19f + textSize = 20f maxLines = 1 ellipsize = TextUtils.TruncateAt.END accessibilityHeading() From f9ea3b22030a333a5c6a1218511139a1d8e9ab31 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:04:56 +0900 Subject: [PATCH 074/125] fix: drop non-positive stored member-order UIDs before settings validation --- .../UserSettingsPreferencesIntegrationTest.kt | 18 ++++++++++++++++++ .../app/settings/UserSettingsPreferences.kt | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt index 29333c5..d70ec9c 100644 --- a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt @@ -79,6 +79,22 @@ class UserSettingsPreferencesIntegrationTest { assertEquals(WeeklyCutlines(), scoped.read()) } + @Test + fun invalidStoredMemberOrderNeverBlocksAScopedSettingsReplacement() { + context.getSharedPreferences("user_settings", Context.MODE_PRIVATE).edit() + .putInt("schema_version", 4) + .putString("member_order", "0,-5,7,7") + .commit() + val storageId = "0".repeat(32) + val store = ScopedAppSettingsStore(context, storageId) + + assertEquals(listOf(7L), UserSettingsPreferences.read(context).memberOrder) + store.replace(store.read().copy(memberOrder = listOf(3L))) + + assertEquals(listOf(3L), MemberOrderPreferences(context, storageId).read()) + assertEquals(listOf(7L), UserSettingsPreferences.memberOrder(context)) + } + private fun clearSettings() { listOf( "user_settings", @@ -88,6 +104,8 @@ class UserSettingsPreferencesIntegrationTest { "weekly_member_order", "weekly_cutlines", "platoon_weekly_cutlines", + "platoon_member_order", + "platoon_timezones", ".app.MainActivity", ).forEach { name -> context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit() 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 ad59e55..117060e 100644 --- a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt @@ -112,7 +112,7 @@ internal object UserSettingsPreferences { fun setMemberOrder(context: Context, uids: List): Boolean = synchronized(lock) { preferencesLocked(context.applicationContext).edit() - .putString(KEY_MEMBER_ORDER, uids.distinct().joinToString(",")) + .putString(KEY_MEMBER_ORDER, uids.filter { it > 0L }.distinct().joinToString(",")) .commit() } @@ -209,7 +209,7 @@ internal object UserSettingsPreferences { val memberOrder = parseMemberOrder( context.getSharedPreferences("weekly_member_order", Context.MODE_PRIVATE) .safeString("uids"), - ).filter { it > 0L } + ) val legacyCutlines = readLegacyCutlines( context.getSharedPreferences("weekly_cutlines", Context.MODE_PRIVATE), ) @@ -317,9 +317,11 @@ internal object UserSettingsPreferences { private fun SharedPreferences.safeInt(key: String): Int? = runCatching { getInt(key, 0) }.getOrNull().takeIf { contains(key) } + /** Only positive UIDs are member identities; anything else would fail settings validation. */ private fun parseMemberOrder(value: String?): List = value.orEmpty() .split(',') .mapNotNull(String::toLongOrNull) + .filter { it > 0L } .distinct() private fun payloadKey(payloadType: Int) = "payload_$payloadType" From 215b0c14cb2584fa1d2ee7b5b97112631cbdcccc Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:05:21 +0900 Subject: [PATCH 075/125] docs: native flow id lifetime, discarded partial rosters and current JNI keep rules --- app/proguard-rules.pro | 3 ++- docs/ARCHITECTURE.md | 3 ++- docs/PLATOON_CAPTURE.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 020cbf8..9580f97 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -2,7 +2,8 @@ -keep class dev.gf2log.app.capture.NativeCaptureBridge { *; } -keep interface dev.gf2log.app.capture.NativeCaptureBridge$PayloadListener { *; } -keepclassmembers class * implements dev.gf2log.app.capture.NativeCaptureBridge$PayloadListener { - public void onPayload(long, boolean, byte[]); + public void onFlowOpened(long, int, java.lang.String, int, java.lang.String, int); + public void onPayload(long, byte[]); public void onFlowClosed(long); public void onTraffic(long, long, long); public void onCaptureStopped(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 930e4e1..43dc6bd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -110,6 +110,7 @@ IP geolocation, and CDN addresses are not accepted as identity evidence. fragments. The first continuation is checked before retention; exceeding either limit discards the pending payload and quarantines its remaining fragments through the logical terminal frame before later datasets are parsed. + 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. - One serial `CaptureWorker` owns every parser, flow, session, and admission @@ -165,7 +166,7 @@ preserving in-flow ordering. 1. Start forwarding using the supplied TUN file descriptor. 2. Stop and release native resources. -The listener reports candidate plaintext payloads after TCP reassembly, flow closure, unexpected native termination, and aggregate byte counters. Every upstream socket is passed through `VpnService.protect()` so forwarded traffic cannot re-enter the VPN. +The listener reports candidate plaintext payloads after TCP reassembly, flow closure, unexpected native termination, and aggregate byte counters. Flow ids are unique for the life of the process, so a pending Platoon that outlives one capture can never refer to a flow of the next. Every upstream socket is passed through `VpnService.protect()` so forwarded traffic cannot re-enter the VPN. The implementation uses zdtun rather than introducing a new TCP/IP stack. General PCAP export, nDPI classification, TLS decryption, root capture, remote diff --git a/docs/PLATOON_CAPTURE.md b/docs/PLATOON_CAPTURE.md index b8f4e49..74eff76 100644 --- a/docs/PLATOON_CAPTURE.md +++ b/docs/PLATOON_CAPTURE.md @@ -83,7 +83,7 @@ preferred source for Join/Withdraw history and Daily Patrol facts. Type `21917` remains the authoritative current roster, and type `21935` remains incremental supporting activity evidence. -The reference may receive one logical dataset across multiple inner payloads. It continues when the payload type matches and the previous outer message id is `0`, or when both outer message ids match. mobileGF2logger preserves that batching rule when writing CSV files. +The reference may receive one logical dataset across multiple inner payloads. It continues when the payload type matches and the previous outer message id is `0`, or when both outer message ids match. mobileGF2logger applies that batching rule once, in the stream parser. A dataset cut short by a malformed frame, an unrelated payload, or flow closure is discarded whole and never merged into a later roster, so a partial roster can never mark the missing members as withdrawn. The reference reports Platoon data during login, sometimes twice during login, after reconnection, and on Platoon pages. The 21935 and 21960 responses are From 9b274a673ce935bc878f416693657f83e63f352d Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:05:36 +0900 Subject: [PATCH 076/125] fix: retry hint names the current capture controls --- app/src/main/res/values-ko/strings_capture.xml | 2 +- app/src/main/res/values/strings_capture.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-ko/strings_capture.xml b/app/src/main/res/values-ko/strings_capture.xml index cb575a9..ce1c59c 100644 --- a/app/src/main/res/values-ko/strings_capture.xml +++ b/app/src/main/res/values-ko/strings_capture.xml @@ -9,7 +9,7 @@ VPN을 설정할 수 없습니다: %1$s VPN 권한이 취소되었습니다 네이티브 전달 코어를 시작하지 못했습니다 - 캡처가 예기치 않게 중지되었습니다. 다시 시도하려면 캡처 준비를 누르세요 + 캡처가 예기치 않게 중지되었습니다. 다시 시작해 주세요. 서클 명단, 활동, 동향을 모두 캡처했습니다 서클 명단을 캡처했지만 대기 시간이 지나도록 %1$s 항목이 없습니다 전달 %1$s KiB · 검사 %2$s KiB · 디코딩 %3$s건 · 경고 %4$s건 · 누락 %5$s건 diff --git a/app/src/main/res/values/strings_capture.xml b/app/src/main/res/values/strings_capture.xml index f8daad2..f3cd7ae 100644 --- a/app/src/main/res/values/strings_capture.xml +++ b/app/src/main/res/values/strings_capture.xml @@ -9,7 +9,7 @@ Unable to establish VPN: %1$s VPN permission was revoked Native forwarding core failed to start - Capture stopped unexpectedly; press Prepare capture to retry + 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 From db446bdfb801097510a2368d5f292f18caf0dd5a Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:06:09 +0900 Subject: [PATCH 077/125] fix: list languages in the same order in onboarding and settings --- app/src/main/java/dev/gf2log/app/OnboardingActivity.kt | 4 ++-- app/src/main/java/dev/gf2log/app/OptionsActivity.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt index 0a05783..d7b07ab 100644 --- a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt @@ -83,8 +83,8 @@ class OnboardingActivity : BaseActivity() { Controls.segmentedControl( context = context, options = listOf( - LanguagePreferences.DEFAULT_LANGUAGE to "English", - LanguagePreferences.KOREAN to "한국어", + LanguagePreferences.DEFAULT_LANGUAGE to getString(R.string.language_english), + LanguagePreferences.KOREAN to getString(R.string.language_korean_native), ), selectedValue = LanguagePreferences.get(context), ) { language -> diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 480d45f..7e67e75 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -120,8 +120,8 @@ class OptionsActivity : BaseActivity() { addView(Controls.segmentedControl( context = this@OptionsActivity, options = listOf( - LanguagePreferences.KOREAN to getString(R.string.language_korean_native), LanguagePreferences.DEFAULT_LANGUAGE to getString(R.string.language_english), + LanguagePreferences.KOREAN to getString(R.string.language_korean_native), ), selectedValue = LanguagePreferences.get(this@OptionsActivity), ) { language -> From f0edcbe192e7922b0947289222479e596594950a Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:06:11 +0900 Subject: [PATCH 078/125] refactor: import fully qualified types and drop an unread boundary field --- .../main/java/dev/gf2log/app/management/BackupArchive.kt | 5 +++-- .../dev/gf2log/app/management/DatabaseSchemaContract.kt | 5 +++-- .../dev/gf2log/app/management/ExactBoundaryCorrelation.kt | 8 +++----- .../gf2log/app/management/PlatoonProfileAdministration.kt | 3 ++- .../java/dev/gf2log/app/settings/AppBackupSettings.kt | 5 +++-- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt index 6f4e8b5..2c5ca41 100644 --- a/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt +++ b/app/src/main/java/dev/gf2log/app/management/BackupArchive.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.management import dev.gf2log.app.StrictProperties +import dev.gf2log.app.settings.GameServerRegion import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.ByteArrayInputStream @@ -197,7 +198,7 @@ internal object BackupArchive { internal data class BackupPlatoonProfile( val storageId: String, val client: PlatoonClient, - val serverRegion: dev.gf2log.app.settings.GameServerRegion, + val serverRegion: GameServerRegion, val platoonId: Long, val platoonName: String, val bannerFrameId: Long, @@ -230,7 +231,7 @@ internal object BackupArchive { private fun Properties.profile(formatVersion: Int): BackupPlatoonProfile { val client = PlatoonClient.valueOf(required(KEY_PROFILE_CLIENT)) - val region = dev.gf2log.app.settings.GameServerRegion.fromStored(required(KEY_PROFILE_REGION)) + val region = GameServerRegion.fromStored(required(KEY_PROFILE_REGION)) require(region.storedValue == required(KEY_PROFILE_REGION)) { "Backup server region is invalid" } val id = requireNotNull(required(KEY_PROFILE_PLATOON_ID).toLongOrNull()) { "Backup Platoon ID is invalid" diff --git a/app/src/main/java/dev/gf2log/app/management/DatabaseSchemaContract.kt b/app/src/main/java/dev/gf2log/app/management/DatabaseSchemaContract.kt index 2cf994d..318a620 100644 --- a/app/src/main/java/dev/gf2log/app/management/DatabaseSchemaContract.kt +++ b/app/src/main/java/dev/gf2log/app/management/DatabaseSchemaContract.kt @@ -1,5 +1,6 @@ package dev.gf2log.app.management +import android.database.Cursor import android.database.sqlite.SQLiteDatabase internal data class DatabaseSchemaContract( @@ -121,7 +122,7 @@ internal data class DatabaseSchemaContract( } private fun readColumns( - cursor: android.database.Cursor, + cursor: Cursor, hiddenColumn: Int?, ): Map = cursor.run { buildMap { @@ -228,7 +229,7 @@ internal data class DatabaseSchemaContract( private fun normalizeSql(value: String): String = value.trim().replace(WHITESPACE, " ").uppercase() - private fun android.database.Cursor.getStringOrNull(column: Int): String? = + private fun Cursor.getStringOrNull(column: Int): String? = if (isNull(column)) null else getString(column) private val AUTO_INCREMENT = Regex("\\bAUTOINCREMENT\\b", RegexOption.IGNORE_CASE) 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 a02a2db..b25b50d 100644 --- a/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt +++ b/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt @@ -94,7 +94,7 @@ internal object ExactBoundaryCorrelation { } val latestBoundary = db.rawQuery( """ - SELECT event_type, note, occurred_at + SELECT note, occurred_at FROM member_events WHERE uid = ? AND source = ? @@ -116,9 +116,8 @@ internal object ExactBoundaryCorrelation { ).use { cursor -> if (!cursor.moveToFirst()) return LatestMembershipBoundary( - type = MemberEventType.valueOf(cursor.getString(0)), - name = cursor.getString(1), - occurredAt = Instant.ofEpochMilli(cursor.getLong(2)), + name = cursor.getString(0), + occurredAt = Instant.ofEpochMilli(cursor.getLong(1)), ) } db.update( @@ -133,7 +132,6 @@ internal object ExactBoundaryCorrelation { } private data class LatestMembershipBoundary( - val type: MemberEventType, val name: String, val occurredAt: Instant, ) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt index a254111..c610d5b 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileAdministration.kt @@ -6,6 +6,7 @@ import dev.gf2log.app.settings.GameServerRegion import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences +import java.io.File import java.time.ZoneId /** Owns destructive and derived-data maintenance for isolated Platoon profiles. */ @@ -111,7 +112,7 @@ internal class PlatoonProfileAdministration internal constructor( } } val root = scope.rootDirectory(context).canonicalFile - val profilesRoot = java.io.File(context.filesDir, "platoons").canonicalFile + val profilesRoot = File(context.filesDir, "platoons").canonicalFile check(root.parentFile == profilesRoot) { "Refusing to delete outside the isolated Platoon directory" } diff --git a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt index aae4180..c5dde51 100644 --- a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt +++ b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt @@ -1,6 +1,7 @@ package dev.gf2log.app.settings import dev.gf2log.app.StrictProperties +import dev.gf2log.protocol.Gfl2PayloadDecoder import dev.gf2log.protocol.PayloadCatalog import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -100,7 +101,7 @@ object AppBackupSettingsCodec { payloadHistory = PayloadCatalog.categories.associate { category -> category.payloadType to if ( schemaVersion < SCHEMA_VERSION && - category.payloadType == dev.gf2log.protocol.Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE + category.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE ) { true } else { @@ -164,7 +165,7 @@ object AppBackupSettingsCodec { } + PayloadCatalog.categories .filterNot { schemaVersion < SCHEMA_VERSION && - it.payloadType == dev.gf2log.protocol.Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE + it.payloadType == Gfl2PayloadDecoder.TYPE_PLATOON_PROFILE } .map { payloadKey(it.payloadType) } From bd06d6669804e8355268a8cdb59932dc270ec929 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:06:32 +0900 Subject: [PATCH 079/125] fix: date, time and sort fields grow with the font size instead of clipping --- app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt | 8 +++++--- app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt index 042ac84..de0ece8 100644 --- a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt +++ b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt @@ -62,8 +62,8 @@ 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, height = 48, weight = 1.3f)) - addView(timeField, context.lp(width = 0, height = 48, weight = 1f, start = 8)) + 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) { @@ -76,7 +76,9 @@ internal class DateTimePickerInput( private fun pickerField(icon: Int, onClick: () -> Unit): TextView = context.text(null, TextRole.BODY) { gravity = Gravity.CENTER_VERTICAL - setPadding(context.dp(12), 0, context.dp(8), 0) + // 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)) diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index a716975..aa9a48e 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -105,8 +105,8 @@ class MemberOrderActivity : BaseActivity() { addView(panel(padding = 12) { addView(text(getString(R.string.sort_once), TextRole.LABEL), lp()) addView(row { - addView(fieldButton, lp(width = 0, height = 48, weight = 1.4f)) - addView(directionButton, lp(width = 0, height = 48, weight = 1f, start = 8)) + addView(fieldButton, lp(width = 0, weight = 1.4f)) + addView(directionButton, lp(width = 0, weight = 1f, start = 8)) }, lp(top = 8)) addView(button(getString(R.string.apply_sort), Controls.Role.SECONDARY) { confirmSort() }, lp(top = 8)) }, lp(top = 12, bottom = 8)) @@ -115,7 +115,8 @@ class MemberOrderActivity : BaseActivity() { private fun choiceChip(onClick: () -> Unit): TextView = text(null, TextRole.BODY) { gravity = Gravity.CENTER_VERTICAL - setPadding(dp(12), 0, dp(8), 0) + minHeight = dp(48) + setPadding(dp(12), dp(6), dp(8), dp(6)) maxLines = 1 ellipsize = TextUtils.TruncateAt.END background = android.graphics.drawable.GradientDrawable().apply { From 1e0d94a4045f94fca9a53558b6804d1cb6e7003d Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:07:41 +0900 Subject: [PATCH 080/125] fix: CSV import summary reads correctly for a single file --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94cdf31..8ece25e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -320,7 +320,7 @@ %1$s — %2$s Validating CSV files and preparing preview… Creating checkpoint and importing CSV files… - Imported: %1$d files kept, %2$d current, %3$d older snapshots, %4$d skipped. You can undo this from Home. + Import done. Files kept: %1$d · current: %2$d · older snapshots: %3$d · skipped: %4$d. You can undo this from Home. Undo last CSV import Restore the automatic checkpoint from immediately before the last CSV import? Undo is refused if Platoon data changed after that import. No CSV import checkpoint is available. From 88a9ae73dd5bcd5addc28dc631ce42ac1f798d6e Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:07:42 +0900 Subject: [PATCH 081/125] fix: replay out-of-order captured rosters instead of treating them as current --- .../PlatoonDatabaseIntegrationTest.kt | 36 +++++++++++++++++++ .../app/management/PlatoonIngestResults.kt | 4 ++- .../app/management/PlatoonRepository.kt | 7 +++- .../app/management/SnapshotIngestion.kt | 6 +++- 4 files changed, 50 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 a3a1266..018a77b 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonDatabaseIntegrationTest.kt @@ -475,6 +475,42 @@ class PlatoonDatabaseIntegrationTest { assertForeignKeysValid() } + @Test + fun anOutOfOrderCapturedRosterIsReplayedWithoutWithdrawingCurrentMembers() { + val newer = Instant.parse("2026-08-02T00:00:00Z") + val older = Instant.parse("2026-08-01T00:00:00Z") + database.ingestSnapshot( + PlatoonSnapshot( + id = 0, + capturedAt = newer, + sourceFile = "newer.csv", + members = listOf(member(TARGET_UID, "Target"), member(OTHER_UID, "Other")), + ), + EvidenceSource.SNAPSHOT, + ) + + val late = database.ingestSnapshot( + PlatoonSnapshot( + id = 0, + capturedAt = older, + sourceFile = "older.csv", + members = listOf(member(TARGET_UID, "Target")), + ), + EvidenceSource.SNAPSHOT, + ) + + assertTrue(late.historical) + assertEquals(0, late.left) + val statuses = database.listMemberStatuses().associateBy(MemberStatus::uid) + assertTrue(statuses.getValue(TARGET_UID).isActive) + assertTrue(statuses.getValue(OTHER_UID).isActive) + val otherPeriod = statuses.getValue(OTHER_UID).membershipPeriods.single() + assertEquals(newer, otherPeriod.joinedAt) + assertEquals(null, otherPeriod.leftAt) + assertEquals(2L, count("snapshots", "id > ?", 0)) + assertForeignKeysValid() + } + @Test fun databaseRejectsRosterBoundsForEveryIngestionCaller() { val oversizedRoster = (1..257).map { index -> member(index.toLong(), "Member $index") } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonIngestResults.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonIngestResults.kt index 4c7bbbc..b1d04d1 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonIngestResults.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonIngestResults.kt @@ -8,11 +8,13 @@ data class SnapshotIngestResult( val rejoined: Int, val left: Int, val renamed: Int, + /** True when the roster predated current data and was replayed into history instead. */ + val historical: Boolean = false, ) { companion object { fun duplicate() = SnapshotIngestResult(null, true, false, 0, 0, 0, 0) fun historical(snapshotId: Long) = - SnapshotIngestResult(snapshotId, false, false, 0, 0, 0, 0) + SnapshotIngestResult(snapshotId, false, false, 0, 0, 0, 0, historical = true) } } 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 94972f2..26404ea 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -45,7 +45,12 @@ internal class PlatoonRepository( source = EvidenceSource.SNAPSHOT, ) } - if (!result.duplicate) recordChangedWeeks(setOf(capturedAt)) + when { + result.duplicate -> Unit + // A replayed older roster can rebuild membership periods across every week. + result.historical -> recordAllLiveWeeklyReports() + else -> recordChangedWeeks(setOf(capturedAt)) + } return result } 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 5512ff5..e2b4c55 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotIngestion.kt @@ -37,7 +37,11 @@ internal object SnapshotIngestion { return@inTransaction SnapshotIngestResult.duplicate() } val priorCapturedAt = latestSnapshotCapturedAt(db) - if (historicalOnly) { + // A roster older than the stored current one cannot describe current presence (a + // late packet or a clock moved backwards), so it is replayed chronologically. + val historical = historicalOnly || + priorCapturedAt?.let(snapshot.capturedAt::isBefore) == true + if (historical) { require(priorCapturedAt != null && !snapshot.capturedAt.isAfter(priorCapturedAt)) { "Historical snapshots must not be newer than current structured data" } From 4e0892534049beee538e82f9916de1512970065b Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:11:08 +0900 Subject: [PATCH 082/125] fix: allow editing the unknown join of a withdrawal-only period; document domain limits --- .../main/java/dev/gf2log/app/MemberDetailActivity.kt | 7 ++++--- docs/PLATOON_MANAGEMENT.md | 10 +++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index aa1580f..83d0679 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -12,7 +12,8 @@ import dev.gf2log.app.management.MemberStatus import dev.gf2log.app.management.MembershipPeriod import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotMember -import dev.gf2log.app.management.isImmutableMembershipBoundary +import dev.gf2log.app.management.isJoinedBoundaryEditable +import dev.gf2log.app.management.isLeftBoundaryEditable import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.ui.onTextChanged @@ -277,7 +278,7 @@ class MemberDetailActivity : BaseActivity() { initialDate = period.joinedDate, initialTimeKnown = period.joinedTimeKnown, dateRequired = true, - editable = !period.joinedSource.isImmutableMembershipBoundary(), + editable = period.isJoinedBoundaryEditable, zone = zone, ) val left = DateTimePickerInput( @@ -286,7 +287,7 @@ class MemberDetailActivity : BaseActivity() { initialValue = period.leftAt, initialDate = period.leftDate, initialTimeKnown = period.leftTimeKnown ?: (period.leftAt != null), - editable = period.leftSource?.isImmutableMembershipBoundary() != true, + editable = period.isLeftBoundaryEditable, zone = zone, ) val noteInput = textField(getString(R.string.membership_note_hint), singleLine = false).apply { setText(period.note) } diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 624a1d4..17dee51 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -195,6 +195,14 @@ transitions. - Weekly history is a derived audit trail, not the primary store. If refreshing one revision fails after a successful data mutation, the affected restored selection is cleared so the live database projection remains authoritative. +- Weekly notes and manual overrides must fall inside their own week; anything + else is rejected before it can make a revision fail its structural checks. +- A captured roster older than the latest stored one, from a late packet or a + clock moved backwards, is replayed into history instead of being treated as + the current roster, so it never withdraws members of a newer roster. +- A consumed CSV import checkpoint is retired by an atomic rename to + `csv-import-checkpoint.discarded` before deletion, so a crash mid-cleanup can + never block later imports or undo. ## Persistence and privacy @@ -211,7 +219,7 @@ The same neutralization is applied to captured roster, activity, Updates, and formation packet tables before they can be copied or opened in spreadsheet software. -Untrusted evidence is bounded at every persistence boundary. A roster import +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 From 5be23a9feda5f0084124185f3ac697020377e7c4 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:19:28 +0900 Subject: [PATCH 083/125] fix: keep installed-client badges legible on the running capture panel --- app/src/main/java/dev/gf2log/app/MainActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 3c7d525..5c93744 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -289,7 +289,8 @@ class MainActivity : BaseActivity() { }, lp(width = 0, weight = 1f)) addView(badge( status, - if (installed) R.color.success_surface else R.color.surface_variant, + // Page-colored fill keeps the badge legible on the green running panel too. + if (installed) R.color.app_background else R.color.surface_variant, if (installed) R.color.success_text else R.color.text_secondary, ).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(WRAP, WRAP, start = 8)) }, lp(top = if (index == 0) 0 else 6)) From b1221948ad45d750b217285ef4d22cc45b495ef6 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:21:29 +0900 Subject: [PATCH 084/125] build: move Kotlin from 2.4.20-Beta1 to stable 2.4.21 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 6df953a..bbe300b 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.20-Beta1' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.21' } } From 662dd75846eb47a9c94168fadcd2c3e9bdac1cd5 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:21:49 +0900 Subject: [PATCH 085/125] ci: note the stable Kotlin version beside the CodeQL compatibility pin --- .github/codeql/codeql-kotlin-compat.init.gradle | 2 +- .github/workflows/codeql.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/codeql/codeql-kotlin-compat.init.gradle b/.github/codeql/codeql-kotlin-compat.init.gradle index 5e7a357..be39914 100644 --- a/.github/codeql/codeql-kotlin-compat.init.gradle +++ b/.github/codeql/codeql-kotlin-compat.init.gradle @@ -1,5 +1,5 @@ // CodeQL 2.26.x can analyze Kotlin 2.4.0 through 2.4.19, while the production -// build must use 2.4.20-Beta1 or newer to address GHSA-r937-wjx7-w2jp. +// build uses 2.4.21 (2.4.20 or newer addresses GHSA-r937-wjx7-w2jp). // This init script is used only by the isolated, cache-disabled CodeQL compile. gradle.beforeProject { project -> project.buildscript.configurations.configureEach { configuration -> diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index aea2bd7..e6c56c1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -102,7 +102,7 @@ jobs: env: # Keep the scanner-only compatibility compiler isolated from every # persistent Gradle cache. Production builds continue to use the - # patched 2.4.20-Beta1 plugin declared in build.gradle. + # patched 2.4.21 plugin declared in build.gradle. GRADLE_USER_HOME: ${{ runner.temp }}/codeql-gradle-home run: > ./gradlew From 8a893893132e3b511e6c3baaff008613459d8032 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Thu, 15 Oct 2026 15:22:03 +0900 Subject: [PATCH 086/125] docs: changelog for the second 2.5.0 pass --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 021899f..a7577a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ All notable changes to mobileGF2logger are documented here. - Restore either backup kind from one Settings entry, which detects whether the archive includes app settings; replay the welcome guide from Settings. - Localize every label in shared weekly PNGs. +- Open the weekly grid at today's column, add Apply, next day to cell edits, and + show Home's latest roster change with joined/withdrew counts. +- Add a clear button to Platoon search and confirm before a sort replaces a + hand-arranged member order. ### Changed @@ -34,6 +38,12 @@ All notable changes to mobileGF2logger are documented here. single transactional owner, and split the capture service into a serial worker, flow engine, admission coordinator, and notification helper. - Add AndroidX Activity as the only new dependency. +- Raise secondary-text and status-mark contrast, keep the accent for tappable or + selected items, mark today in the weekly header, fill below-cutline cells, and + keep rank highlights for the top three. +- Show Undo last CSV import only while a checkpoint exists; scale weekly rows + and date fields with the system font size. +- Build with stable Kotlin 2.4.21 instead of 2.4.20-Beta1. ### Fixed @@ -62,6 +72,24 @@ All notable changes to mobileGF2logger are documented here. - Move every repository, file, and network call off the main thread. - Truncate document-provider files when exporting so overwritten files cannot keep stale trailing bytes. +- Stop a native double free when a closing flow's reset cannot be written, keep + native flow ids unique across captures, clear pending JNI exceptions, and + drive native timers from the monotonic clock. +- Discard a continued roster whose frame is malformed or cut short instead of + publishing it as complete, so missing members are never marked withdrawn. +- Quarantine a bound flow that reports a different, unregistered Platoon, + release a pending claim when confirmation fails, and keep the per-capture + admission limit across service restarts. +- Replay out-of-order captured rosters into history, derive game days from + local 05:00 across daylight-saving changes, export a rejoined member's + current period, and keep the unknown join of a withdrawal-only period + editable. +- Retire CSV import checkpoints atomically, keep weekly notes and overrides + inside their week, render the live weekly table when history recording + fails, and restore the exact timezone when a server change fails. +- Report admission failures inside the New Platoon dialog, block repeated + send/share/backup/restore taps, ask before leaving an edited member, and + dismiss a screen's dialogs when it closes. ## 2.4.2 - 2026-08-27 From 710c997763a977433e7ce8a4ed48a547f8704d3d Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 01:54:26 +0900 Subject: [PATCH 087/125] fix: stop capture in place for guarded actions and keep capture times in device time - Backup, restore, CSV import/undo and profile changes offer Stop capture instead of a passing toast - New Platoon dialog preselects the capture server chosen in Settings - Roster capture times use device time on Home, Platoon and comparison, matching packet history - Screens read the reset zone from their own repository scope - Off switches use outline ink so they stay visible in dark dialogs - Drop unused imports --- .../main/java/dev/gf2log/app/CsvImportFlow.kt | 16 ++------ .../main/java/dev/gf2log/app/MainActivity.kt | 21 +++-------- .../dev/gf2log/app/MemberDetailActivity.kt | 3 +- .../java/dev/gf2log/app/OptionsActivity.kt | 15 ++------ .../dev/gf2log/app/PacketHistoryActivity.kt | 3 -- .../app/PendingPlatoonAdmissionPrompt.kt | 5 +++ .../java/dev/gf2log/app/PlatoonActivity.kt | 9 ++--- .../dev/gf2log/app/PlatoonProfileSelector.kt | 21 +++++------ .../gf2log/app/SnapshotComparisonActivity.kt | 11 +++--- .../main/java/dev/gf2log/app/WeeklyDialogs.kt | 1 - .../main/java/dev/gf2log/app/WeeklyTable.kt | 1 - .../app/management/PlatoonRepository.kt | 13 ++++--- .../java/dev/gf2log/app/ui/CaptureGuard.kt | 37 +++++++++++++++++++ .../main/java/dev/gf2log/app/ui/Controls.kt | 5 ++- app/src/main/java/dev/gf2log/app/ui/Labels.kt | 4 -- app/src/main/res/values-ko/strings.xml | 10 +++-- app/src/main/res/values/strings.xml | 10 +++-- docs/PLATOON_MANAGEMENT.md | 2 + 18 files changed, 99 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt diff --git a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt index 886ed36..9288333 100644 --- a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt +++ b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt @@ -4,7 +4,6 @@ import android.net.Uri import android.os.Bundle import android.view.Gravity import androidx.activity.result.contract.ActivityResultContracts -import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.management.CsvImportCheckpointManager import dev.gf2log.app.management.CsvImportPreviewAnalyzer import dev.gf2log.app.management.PlatoonCsvImportStore @@ -14,6 +13,7 @@ import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.CaptureGuard import dev.gf2log.app.ui.captureActive import dev.gf2log.app.ui.DocumentAccess import dev.gf2log.app.ui.Feedback @@ -71,22 +71,14 @@ internal class CsvImportFlow( launchPicker() } - fun start() { - if (captureActive) { - Feedback.show(activity, R.string.stop_capture_before_csv_import, long = true) - return - } + fun start() = CaptureGuard.whenStopped(activity, R.string.stop_capture_before_csv_import) { PlatoonCsvImportPrompt.show(activity) { profile -> beginFor(profile) } } - fun undo() { - if (captureActive) { - Feedback.show(activity, R.string.stop_capture_before_csv_import, long = true) - return - } + fun undo() = CaptureGuard.whenStopped(activity, R.string.stop_capture_before_csv_import) { val scope = boundScope() ?: run { Feedback.show(activity, R.string.no_platoon_detected_detail, long = true) - return + return@whenStopped } work.run(task = { CsvImportCheckpointManager(activity, scope).canUndo() }) { result -> if (!result.getOrDefault(false)) { diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 5c93744..6e3885b 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -30,10 +30,10 @@ import dev.gf2log.app.management.CsvImportCheckpointManager import dev.gf2log.app.management.PlatoonCsvImportStore import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotComparison -import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.AccessibleRow import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.CaptureGuard import dev.gf2log.app.ui.BarAction import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.DocumentAccess @@ -47,7 +47,6 @@ import dev.gf2log.app.ui.button import dev.gf2log.app.ui.column import dev.gf2log.app.ui.divider import dev.gf2log.app.ui.dp -import dev.gf2log.app.ui.dpf import dev.gf2log.app.ui.group import dev.gf2log.app.ui.icon import dev.gf2log.app.ui.lp @@ -61,6 +60,7 @@ import dev.gf2log.app.ui.text import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.Gfl2PayloadDecoder import java.io.File +import java.time.ZoneId /** * Home: capture state and actions first, then the selected Platoon, data tools, and recent @@ -436,10 +436,7 @@ class MainActivity : BaseActivity() { captureOnceRequested = false } - private fun stopCaptureService() { - startService(Intent(this, CaptureVpnService::class.java).setAction(CaptureVpnService.ACTION_STOP)) - CaptureStatus.markStopped() - } + private fun stopCaptureService() = CaptureGuard.stop(this) private fun isInstalled(packageName: String): Boolean = runCatching { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -493,13 +490,7 @@ class MainActivity : BaseActivity() { task = { val snapshots = PlatoonRepository(this, scope).listSnapshots(2) val latest = snapshots.firstOrNull() - latest?.let { - Triple( - it, - snapshots.getOrNull(1)?.let { older -> SnapshotComparison.compare(older, it) }, - GameTimeZonePreferences.get(this, scope.storageId), - ) - } + latest?.let { it to snapshots.getOrNull(1)?.let { older -> SnapshotComparison.compare(older, it) } } }, ) { result -> val summary = result.getOrNull() @@ -507,7 +498,7 @@ class MainActivity : BaseActivity() { rosterSummary.visibility = View.GONE return@run } - val (latest, comparison, zone) = summary + val (latest, comparison) = summary rosterSummary.removeAllViews() rosterSummary.visibility = View.VISIBLE val detail = buildList { @@ -519,7 +510,7 @@ class MainActivity : BaseActivity() { }.joinToString(" · ") rosterSummary.addView(group(listOf( navigationRow( - getString(R.string.latest_roster_title, Labels.format(Labels.DATE_TIME, latest.capturedAt, zone)), + getString(R.string.latest_roster_title, Labels.format(Labels.DATE_TIME, latest.capturedAt, ZoneId.systemDefault())), detail, R.drawable.ic_compare, enabled = comparison != null, diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 83d0679..b1356bb 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -15,7 +15,6 @@ import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.management.isJoinedBoundaryEditable import dev.gf2log.app.management.isLeftBoundaryEditable import dev.gf2log.app.management.isValidMembershipRange -import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.ui.onTextChanged import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork @@ -138,7 +137,7 @@ class MemberDetailActivity : BaseActivity() { Model( status = it, latest = repository.listSnapshots(1).firstOrNull()?.members?.firstOrNull { member -> member.uid == uid }, - zone = GameTimeZonePreferences.get(this), + zone = repository.gameZone(), ) } }, diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 7e67e75..601cb30 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -13,7 +13,6 @@ import android.view.View import android.widget.LinearLayout import androidx.activity.result.contract.ActivityResultContracts import dev.gf2log.app.capture.CaptureDiagnosticsStore -import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.discord.DiscordWebhookSecretStore import dev.gf2log.app.management.BackupFileName import dev.gf2log.app.management.InvalidBackupException @@ -29,7 +28,7 @@ import dev.gf2log.app.settings.PayloadHistoryPreferences import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs -import dev.gf2log.app.ui.captureActive +import dev.gf2log.app.ui.CaptureGuard import dev.gf2log.app.ui.DocumentAccess import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels @@ -316,21 +315,13 @@ class OptionsActivity : BaseActivity() { // region Backup - private fun startExport(complete: Boolean) { - if (captureActive) { - Feedback.show(this, R.string.stop_capture_before_backup, long = true) - return - } + private fun startExport(complete: Boolean) = CaptureGuard.whenStopped(this, R.string.stop_capture_before_backup) { pendingExportComplete = complete val prefix = if (complete) "GF2logger-full" else "GF2logger-platoon" exportBackup.launch("$prefix-${Labels.FILE_STAMP.format(LocalDateTime.now())}.${PlatoonBackupManager.FILE_EXTENSION}") } - private fun confirmRestore() { - if (captureActive) { - Feedback.show(this, R.string.stop_capture_before_backup, long = true) - return - } + private fun confirmRestore() = CaptureGuard.whenStopped(this, R.string.stop_capture_before_backup) { Dialogs.confirm(this, R.string.restore_full_backup, R.string.restore_backup_warning, R.string.choose_file, destructive = true) { restoreBackup.launch(arrayOf(PlatoonBackupManager.MIME_TYPE, "application/zip", "application/octet-stream")) } diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index eddd79b..519e4df 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -4,13 +4,10 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Intent import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.view.Gravity -import android.view.View import android.widget.FrameLayout import android.widget.HorizontalScrollView -import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TableLayout import android.widget.TableRow diff --git a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt index b223d45..4c462c4 100644 --- a/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PendingPlatoonAdmissionPrompt.kt @@ -66,6 +66,11 @@ internal object PendingPlatoonAdmissionPrompt { private fun show(activity: Activity, candidate: PendingPlatoonAdmissionStore.Summary) { val client = PlatoonClient.fromPackage(candidate.ownerPackage) ?: return val regions = ClientServerRegionPreferences.allowedFor(candidate.ownerPackage) + // The server chosen in Settings is preselected; the user still confirms it explicitly. + if (candidate.token !in selections) { + ClientServerRegionPreferences(activity).configured(candidate.ownerPackage) + ?.let { selections[candidate.token] = it } + } val radios = RadioGroup(activity).apply { orientation = RadioGroup.VERTICAL } val status = activity.text(null, TextRole.CAPTION) { visibility = View.GONE diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index 0cff639..f39ab35 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -25,7 +25,6 @@ import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonSnapshot import dev.gf2log.app.management.SnapshotMember import dev.gf2log.app.management.isValidMembershipRange -import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.ui.searchField import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.AccessibleRow @@ -88,7 +87,7 @@ class PlatoonActivity : BaseActivity() { task = { val statuses = repository.listMemberStatuses().filter { it.uid in uids } val latest = latestMembers(repository.listSnapshots(1).firstOrNull()) - val csv = PlatoonMemberCsv.format(statuses, latest, GameTimeZonePreferences.get(this)) + val csv = PlatoonMemberCsv.format(statuses, latest, repository.gameZone()) DocumentAccess.openOutput(contentResolver, uri).writer(Charsets.UTF_8).use { it.write(csv) } }, ) { result -> @@ -159,7 +158,7 @@ class PlatoonActivity : BaseActivity() { latest = latest, statuses = repository.listMemberStatuses(), latestMembers = latestMembers(latest), - zone = GameTimeZonePreferences.get(this), + zone = repository.gameZone(), ) }, ) { result -> @@ -224,7 +223,7 @@ class PlatoonActivity : BaseActivity() { getString(R.string.stat_active) to active.toString(), getString(R.string.stat_withdrawn) to (loaded.statuses.size - active).toString(), getString(R.string.stat_last_snapshot) to (loaded.latest?.let { - Labels.format(Labels.DATE_TIME, it.capturedAt, loaded.zone) + Labels.format(Labels.DATE_TIME, it.capturedAt, ZoneId.systemDefault()) } ?: getString(R.string.none)), ) tiles.forEachIndexed { index, (label, value) -> @@ -351,7 +350,7 @@ class PlatoonActivity : BaseActivity() { } private fun showAddWithdrawnMemberDialog() { - val zone = GameTimeZonePreferences.get(this) + val zone = repository.gameZone() val uid = LabeledField( this, getString(R.string.uid), diff --git a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt index 3442ad6..5c6e2c2 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonProfileSelector.kt @@ -9,7 +9,6 @@ import android.view.Gravity import android.view.View import android.widget.ImageView import android.widget.LinearLayout -import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.management.PlatoonProfile import dev.gf2log.app.management.PlatoonProfileAdministration import dev.gf2log.app.management.PlatoonProfileRegistry @@ -17,7 +16,7 @@ import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.ui.onTextChanged import dev.gf2log.app.ui.AccessibleRow import dev.gf2log.app.ui.Dialogs -import dev.gf2log.app.ui.captureActive +import dev.gf2log.app.ui.CaptureGuard import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.TextRole @@ -201,11 +200,10 @@ internal object PlatoonProfileSelector { }, activity.lp(48, 48)) } - private fun chooseProfileRegion(activity: Activity, profile: PlatoonProfile) { - if (captureActive) { - Feedback.show(activity, R.string.stop_capture_before_profile_change, long = true) - return - } + private fun chooseProfileRegion(activity: Activity, profile: PlatoonProfile) = + CaptureGuard.whenStopped(activity, R.string.stop_capture_before_profile_change) { chooseProfileRegionNow(activity, profile) } + + private fun chooseProfileRegionNow(activity: Activity, profile: PlatoonProfile) { val regions = ClientServerRegionPreferences.allowedFor(profile.client.packageName) Dialogs.show( Dialogs.builder(activity) @@ -226,11 +224,10 @@ internal object PlatoonProfileSelector { ) } - private fun confirmDeleteFirst(activity: Activity, profile: PlatoonProfile) { - if (captureActive) { - Feedback.show(activity, R.string.stop_capture_before_profile_change, long = true) - return - } + private fun confirmDeleteFirst(activity: Activity, profile: PlatoonProfile) = + CaptureGuard.whenStopped(activity, R.string.stop_capture_before_profile_change) { confirmDeleteFirstNow(activity, profile) } + + private fun confirmDeleteFirstNow(activity: Activity, profile: PlatoonProfile) { Dialogs.confirm( activity, activity.getString(R.string.delete_platoon_profile), diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index b14fff3..a188d9e 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -5,11 +5,9 @@ import android.content.ClipboardManager import android.os.Bundle import android.text.TextUtils import android.view.View -import android.widget.LinearLayout import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotComparison import dev.gf2log.app.management.SnapshotComparisonCsv -import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.BarAction import dev.gf2log.app.ui.Feedback @@ -51,13 +49,12 @@ class SnapshotComparisonActivity : BaseActivity() { work.run( task = { val snapshots = PlatoonRepository(this, scope).listSnapshots(2) - snapshots.takeIf { it.size == 2 }?.let { SnapshotComparison.compare(it[1], it[0]) } to - GameTimeZonePreferences.get(this) + snapshots.takeIf { it.size == 2 }?.let { SnapshotComparison.compare(it[1], it[0]) } }, ) { result -> content.removeAllViews() result.fold( - onSuccess = { (comparison, zone) -> render(comparison, zone) }, + onSuccess = ::render, onFailure = { content.addView(StateViews.failure(this, getString(R.string.platoon_load_failed)) { recreate() }) }, ) } @@ -67,7 +64,9 @@ class SnapshotComparisonActivity : BaseActivity() { if (!profileBinding.isCurrent(this)) finish() } - private fun render(result: SnapshotComparison.Result?, zone: ZoneId) { + private fun render(result: SnapshotComparison.Result?) { + // Capture instants are shown in device time, like packet history. + val zone = ZoneId.systemDefault() if (result == null) { content.addView(StateViews.empty(this, R.drawable.ic_compare, getString(R.string.need_two_snapshots))) return diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt index 34bb0d5..cd4e765 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -5,7 +5,6 @@ import android.app.AlertDialog import android.text.InputType import android.view.Gravity import android.view.View -import android.widget.LinearLayout import dev.gf2log.app.management.MetricCertainty import dev.gf2log.app.management.WeeklyEvidenceAnalyzer import dev.gf2log.app.management.WeeklyEvidenceAnalyzer.Metric diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt index b6b0a2a..9e504db 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt @@ -11,7 +11,6 @@ import android.view.Gravity import android.view.View import android.widget.HorizontalScrollView import android.widget.LinearLayout -import android.widget.TextView import androidx.annotation.ColorRes import dev.gf2log.app.management.DailyEvidence import dev.gf2log.app.management.MetricCertainty 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 26404ea..9a40375 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -26,6 +26,9 @@ internal class PlatoonRepository( PlatoonBackupManager.recoverInterruptedFullRestore(appContext, storageScope) } + /** The 05:00 reset zone of this repository's own Platoon, never the currently active one. */ + fun gameZone(): ZoneId = GameTimeZonePreferences.get(appContext, storageScope.storageId) + fun ingest( capturedAt: Instant, members: List, @@ -427,7 +430,7 @@ internal class PlatoonRepository( ) { access { it.replaceWeeklyOverrides(periodStartEpochDay, overrides) } val day = LocalDate.ofEpochDay(periodStartEpochDay) - val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) + val zone = gameZone() recordLiveWeeklyRevisionSafely(day, zone) } @@ -468,7 +471,7 @@ internal class PlatoonRepository( recordChangedWeeks(instants.asSequence()) private fun recordChangedWeeks(instants: Sequence) { - val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) + val zone = gameZone() WeeklyHistoryWorkPolicy.changedPeriodStarts(instants, zone) .forEach { periodStart -> recordLiveWeeklyRevisionSafely(periodStart, zone) @@ -477,7 +480,7 @@ internal class PlatoonRepository( /** Refreshes every derived weekly revision after a primary-data mutation. */ private fun recordAllLiveWeeklyReports() { - val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) + val zone = gameZone() weeklyPeriodStarts(zone).forEach { periodStart -> recordLiveWeeklyRevisionSafely(periodStart, zone) } @@ -494,7 +497,7 @@ internal class PlatoonRepository( * fails, every week is refreshed so the mutation stays authoritative. */ private fun refreshingWeeksOf(uid: Long?, mutation: () -> Boolean): Boolean { - val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) + val zone = gameZone() val asOf = Instant.now() val before = uid?.let { runCatching { weeksShowingMember(it, zone, asOf) }.getOrNull() } if (!mutation()) return false @@ -558,7 +561,7 @@ internal class PlatoonRepository( } private fun recordLiveWeeklyRevision(periodStart: LocalDate) { - val zone = GameTimeZonePreferences.get(appContext, storageScope.storageId) + val zone = gameZone() recordLiveWeeklyRevisionSafely(periodStart, zone) } diff --git a/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt b/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt new file mode 100644 index 0000000..94e7ec5 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ui/CaptureGuard.kt @@ -0,0 +1,37 @@ +package dev.gf2log.app.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import dev.gf2log.app.R +import dev.gf2log.app.capture.CapturePhase +import dev.gf2log.app.capture.CaptureStatus +import dev.gf2log.app.capture.CaptureVpnService + +/** True from the moment capture is requested until it stops; risky data operations wait. */ +internal val captureActive: Boolean + get() = CaptureStatus.snapshot().phase != CapturePhase.STOPPED + +/** + * 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. + */ +internal object CaptureGuard { + fun stop(context: Context) { + context.startService(Intent(context, CaptureVpnService::class.java).setAction(CaptureVpnService.ACTION_STOP)) + CaptureStatus.markStopped() + } + + /** Runs [action] now, or after the user agrees to stop the running capture; [reason] says why. */ + fun whenStopped(activity: Activity, @StringRes reason: Int, action: () -> Unit) { + if (!captureActive) { + action() + return + } + Dialogs.confirm(activity, R.string.capture_running_title, reason, R.string.stop_capture_and_continue) { + stop(activity) + action() + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/ui/Controls.kt b/app/src/main/java/dev/gf2log/app/ui/Controls.kt index 06efc65..59a7784 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Controls.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Controls.kt @@ -129,10 +129,11 @@ internal object Controls { intArrayOf(android.R.attr.state_checked), intArrayOf(), ), + // An off thumb in outline ink stays visible on dark dialogs as well as light panels. intArrayOf( - context.getColor(R.color.surface_variant), + context.getColor(R.color.outline), context.getColor(R.color.accent_text), - context.getColor(R.color.surface), + context.getColor(R.color.outline_strong), ), ) 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 e7aac12..e861645 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Labels.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Labels.kt @@ -101,7 +101,3 @@ internal object Labels { }, ) } - -/** True from the moment capture is requested until it fully stops; risky data operations wait. */ -internal val captureActive: Boolean - get() = dev.gf2log.app.capture.CaptureStatus.snapshot().phase != dev.gf2log.app.capture.CapturePhase.STOPPED diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9d24848..c8fd1ca 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -13,7 +13,7 @@ 중지 설정 열기 서클 관리 백업 내보내기 - 백업을 내보내거나 가져오기 전에 캡처를 중지하세요. + 캡처 중에는 패킷이 중간에 섞이지 않도록 백업과 복원을 할 수 없습니다. 서클 관리 백업을 내보냈습니다 언어 English @@ -172,7 +172,7 @@ 감지된 서클의 서버를 수정하거나 격리된 데이터를 완전히 삭제합니다. 선택됨 %1$s 서버 수정 - 서클을 수정하거나 삭제하기 전에 캡처를 중지하세요. + 서클 프로필은 캡처가 멈춘 상태에서만 바꿀 수 있습니다. 서클 서버를 수정할 수 없습니다. 데이터는 이동되지 않았습니다. 서클 삭제 %1$s 삭제 @@ -323,7 +323,7 @@ CSV 가져오기 체크포인트를 복원하는 중… 마지막 CSV 가져오기를 실행 취소했습니다. 체크포인트가 없거나 올바르지 않거나, 가져오기 이후 서클 데이터가 변경되어 되돌릴 수 없습니다. - CSV 미리보기, 가져오기 또는 실행 취소 전에 캡처를 중지하세요. + 명단이 겹쳐 기록되지 않도록 CSV 가져오기와 되돌리기는 캡처가 멈춘 뒤에 할 수 있습니다. 주간 테이블 공유 멤버 이름 포함 UID 포함 @@ -557,7 +557,7 @@ 최근 %1$d/%2$d 필수 백업에 들어 있는 서클만 교체 - 복원하면 백업에 들어 있는 서클의 데이터를 교체하고 그 서클을 선택합니다. 전체 백업이면 앱 설정도 교체됩니다. 다른 서클은 바뀌지 않습니다. 캡처는 중지해 두세요. + 복원하면 백업에 들어 있는 서클의 데이터를 교체하고 그 서클을 선택합니다. 전체 백업이면 앱 설정도 교체됩니다. 다른 서클은 바뀌지 않습니다. 명단 CSV를 내보냈습니다 보관 보관 %1$d/%2$d @@ -590,4 +590,6 @@ 검색어 지우기 직접 옮긴 순서를 포함해 현재 순서를 모두 바꿉니다. 적용 후 다음 날 + 캡처 중입니다 + 캡처 중지 후 계속 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ece25e..b4d5d30 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ Stop Open settings Export Platoon backup - Stop capture before exporting or importing a backup. + Backups and restores wait until capture stops, so no packet lands halfway through. Platoon backup exported Language English @@ -172,7 +172,7 @@ Edit a detected Platoon’s server or permanently delete its isolated data. Active Edit server for %1$s - Stop capture before editing or deleting a Platoon. + Platoon profiles can only be changed while capture is stopped. Unable to update the Platoon server. No data was moved. Delete Platoon Delete %1$s @@ -327,7 +327,7 @@ Restoring the CSV import checkpoint… The last CSV import was undone. Undo is unavailable, invalid, or blocked because Platoon data changed after the import. - Stop capture before previewing, importing, or undoing CSV files. + CSV import and undo wait until capture stops, so the roster isn\'t written twice. Share weekly table Include member names Include UIDs @@ -564,7 +564,7 @@ Recent %1$d/%2$d Required Replaces only the Platoon inside the backup - Restoring replaces the data of the Platoon inside the backup and then selects it. A complete backup also replaces app settings. Other Platoons are unchanged. Keep capture stopped. + Restoring replaces the data of the Platoon inside the backup and then selects it. A complete backup also replaces app settings. Other Platoons are unchanged. Roster CSV exported Save Saved %1$d/%2$d @@ -598,4 +598,6 @@ Clear search This replaces the current order, including any members you moved by hand. Apply, next day + Capture is running + Stop capture diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 17dee51..f7078e7 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -41,6 +41,8 @@ rules below take precedence over stale copied dates in archived templates. remain in a bounded in-memory candidate until the user selects one of the six supported regions compatible with the VPN-owner-verified client. Only then is its immutable scoped repository created and the buffered evidence ingested. + When Settings holds an explicit capture server for that client, the dialog + preselects it; the user still confirms before anything is stored. - Changing a profile's server rebuilds its weekly history without changing its storage scope. Deleting a profile removes only that scope after two warnings and an exact Platoon-name confirmation; interrupted cleanup resumes later. From 7c2b93192ee6e4b889bd607210cfe880a348c37b Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:00:42 +0900 Subject: [PATCH 088/125] fix: track picker dialogs so recreated screens close them --- .../dev/gf2log/app/DateTimePickerInput.kt | 45 ++++++++++--------- .../dev/gf2log/app/WeeklyReportActivity.kt | 22 ++++----- .../main/java/dev/gf2log/app/ui/Dialogs.kt | 14 ++++-- 3 files changed, 48 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt b/app/src/main/java/dev/gf2log/app/DateTimePickerInput.kt index de0ece8..b05c339 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.ui.Dialogs import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.TextRole import dev.gf2log.app.ui.dp @@ -97,30 +98,34 @@ internal class DateTimePickerInput( private fun showDatePicker() { val seed = selection.date ?: LocalDate.now(zone) - DatePickerDialog( - context, - { _, year, month, day -> - selection.selectDate(LocalDate.of(year, month + 1, day)) - renderValue() - }, - seed.year, - seed.monthValue - 1, - seed.dayOfMonth, - ).show() + Dialogs.showTracked( + DatePickerDialog( + context, + { _, year, month, day -> + selection.selectDate(LocalDate.of(year, month + 1, day)) + renderValue() + }, + seed.year, + seed.monthValue - 1, + seed.dayOfMonth, + ), + ) } private fun showTimePicker() { val seed = selection.time ?: LocalTime.now(zone).withSecond(0).withNano(0) - TimePickerDialog( - context, - { _, hour, minute -> - selection.selectTime(LocalTime.of(hour, minute)) - renderValue() - }, - seed.hour, - seed.minute, - android.text.format.DateFormat.is24HourFormat(context), - ).show() + Dialogs.showTracked( + TimePickerDialog( + context, + { _, hour, minute -> + selection.selectTime(LocalTime.of(hour, minute)) + renderValue() + }, + seed.hour, + seed.minute, + android.text.format.DateFormat.is24HourFormat(context), + ), + ) } private fun renderValue() { diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 99e0c80..49d1125 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -645,16 +645,18 @@ class WeeklyReportActivity : BaseActivity() { private fun showDatePicker() { val day = reportState.referenceDay - DatePickerDialog( - this, - { _, year, month, dayOfMonth -> - reportState.selectDate(LocalDate.of(year, month + 1, dayOfMonth)) - requestRender() - }, - day.year, - day.monthValue - 1, - day.dayOfMonth, - ).show() + Dialogs.showTracked( + DatePickerDialog( + this, + { _, year, month, dayOfMonth -> + reportState.selectDate(LocalDate.of(year, month + 1, dayOfMonth)) + requestRender() + }, + day.year, + day.monthValue - 1, + day.dayOfMonth, + ), + ) } private fun onCellTapped(member: WeeklyReportBuilder.MemberRow, cell: WeeklyReportBuilder.DayCell?) { 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 034bf51..1bf594f 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt @@ -34,14 +34,22 @@ internal object Dialogs { } onShown(dialog) } + return showTracked(dialog) + } + + /** + * Shows a platform dialog that is not built through [builder], such as a date or time picker, + * and registers it with its screen so [dismissAll] closes it on recreation. + */ + fun showTracked(dialog: D): D { dialog.show() - owner(builder.context)?.let { owner -> + owner(dialog.context)?.let { owner -> open.getOrPut(owner) { mutableListOf() }.add(java.lang.ref.WeakReference(dialog)) } return dialog } - private val open = java.util.WeakHashMap>>() + private val open = java.util.WeakHashMap>>() private tailrec fun owner(context: Context): android.app.Activity? = when (context) { is android.app.Activity -> context @@ -51,7 +59,7 @@ internal object Dialogs { /** Closes every dialog a screen opened so a recreated screen never leaks its windows. */ fun dismissAll(owner: android.app.Activity) { - open.remove(owner)?.forEach { it.get()?.takeIf(AlertDialog::isShowing)?.dismiss() } + open.remove(owner)?.forEach { it.get()?.takeIf(android.app.Dialog::isShowing)?.dismiss() } } fun confirm( From b6516ea90c702bfc14ee854d82388c1e798a5242 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:01:00 +0900 Subject: [PATCH 089/125] fix: refresh edited weekly rows by their current adapter position --- .../main/java/dev/gf2log/app/WeeklyReportActivity.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 49d1125..57ff763 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -669,12 +669,16 @@ class WeeklyReportActivity : BaseActivity() { Feedback.show(this, R.string.total_not_editable) return } + // The draft is being committed; edits made now would be cleared with it. + if (work.isBusy(SAVE)) return val key = member.uid to cell.gameDay val nextCell = member.days.getOrNull(member.days.indexOf(cell) + 1) val next = nextCell?.let { { onCellTapped(member, it) } } WeeklyDialogs.showEdit(this, title, current.report.isGunsmokeWeek, member.uid, cell, edits[key], next) { edit -> + if (editingPeriod != current.report.periodStart) return@showEdit if (edit.isEmpty) edits.remove(key) else edits[key] = edit - rowsAdapter.notifyItemChanged(current.displayedMembers.indexOfFirst { it.uid == member.uid }) + // Rows are addressed in the list the adapter currently shows, not the tapped model. + rowsAdapter.notifyMemberChanged(member.uid) updateTopBar() } return @@ -867,6 +871,11 @@ class WeeklyReportActivity : BaseActivity() { notifyDataSetChanged() } + fun notifyMemberChanged(uid: Long) { + val position = members.indexOfFirst { it.uid == uid } + if (position >= 0) notifyItemChanged(position) + } + override fun getItemCount() = members.size override fun getItemViewType(position: Int) = if (table?.isGunsmokeWeek == true) GUNSMOKE_ROW else STANDARD_ROW From be2187ff51b8f3a30a41fe5ba639bf84d7f90d92 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:01:11 +0900 Subject: [PATCH 090/125] fix: start weekly editing from the table shown at confirmation --- app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 57ff763..181410b 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -700,8 +700,10 @@ class WeeklyReportActivity : BaseActivity() { } private fun confirmEditing() { - val current = model ?: return + if (model == null) return Dialogs.confirm(this, R.string.manual_edit_warning_title, R.string.manual_edit_warning, R.string.start_editing) { + // A resume may have re-rendered while the warning was open; edit what is shown now. + val current = model ?: return@confirm editingPeriod = current.report.periodStart edits.clear() render(current) From b280c8edb468261553f08a2b9adfa1d0d7cd3ac5 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:01:24 +0900 Subject: [PATCH 091/125] fix: render one weekly PNG per share request --- app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 181410b..deacde4 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -791,9 +791,11 @@ class WeeklyReportActivity : BaseActivity() { } private fun share() { - val current = model ?: return - if (work.isBusy(PNG)) return + if (model == null || work.isBusy(PNG)) return WeeklyDialogs.showShare(this) { privacy, shareAfter -> + // A second share sheet opened by a double tap must not start another render. + val current = model ?: return@showShare + if (work.isBusy(PNG)) return@showShare Feedback.show(this, R.string.weekly_png_rendering) work.run( key = PNG, From 4362b5f9fab0303ad58343b63613efe9a1d5f649 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:01:54 +0900 Subject: [PATCH 092/125] fix: name the export that failed in backup and weekly CSV messages --- app/src/main/java/dev/gf2log/app/OptionsActivity.kt | 6 +++++- app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt | 2 +- app/src/main/res/values-ko/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ .../java/dev/gf2log/app/OptionsBackupFailureMessageTest.kt | 6 ++++++ 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 601cb30..8c7efe0 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -64,7 +64,7 @@ class OptionsActivity : BaseActivity() { runBackup( progress = R.string.backup_exporting, success = if (complete) R.string.full_backup_exported else R.string.status_backup_exported, - failure = { R.string.full_backup_export_failed }, + failure = { backupExportFailureMessage(complete) }, ) { DocumentAccess.openOutput(contentResolver, uri).use { output -> val manager = PlatoonBackupManager(this, scope) @@ -493,3 +493,7 @@ class OptionsActivity : BaseActivity() { /** Known validation failures blame the selected file; anything else is an operational failure. */ internal fun fullBackupRestoreFailureMessage(error: Throwable): Int = if (error is InvalidBackupException) R.string.invalid_full_backup else R.string.full_backup_restore_failed + +/** A Platoon-only export failure must not claim that the complete backup failed. */ +internal fun backupExportFailureMessage(complete: Boolean): Int = + if (complete) R.string.full_backup_export_failed else R.string.status_backup_export_failed diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index deacde4..8177c14 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -123,7 +123,7 @@ class WeeklyReportActivity : BaseActivity() { DocumentAccess.openOutput(contentResolver, uri).writer(Charsets.UTF_8).use { it.write(csv) } }, ) { result -> - Feedback.show(this, if (result.isSuccess) R.string.weekly_csv_exported else R.string.status_export_failed) + Feedback.show(this, if (result.isSuccess) R.string.weekly_csv_exported else R.string.weekly_csv_export_failed) } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c8fd1ca..c037193 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -15,6 +15,7 @@ 서클 관리 백업 내보내기 캡처 중에는 패킷이 중간에 섞이지 않도록 백업과 복원을 할 수 없습니다. 서클 관리 백업을 내보냈습니다 + 서클 관리 백업을 내보낼 수 없습니다 언어 English 한국어 @@ -144,6 +145,7 @@ 비고를 삭제했습니다 주간 CSV 내보내기 주간 CSV를 내보냈습니다 + 주간 CSV를 내보낼 수 없습니다 전체 주간 과업표 내보내기 내보낼 주간 과업표가 없습니다. 설정 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4d5d30..97f66b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ Export Platoon backup Backups and restores wait until capture stops, so no packet lands halfway through. Platoon backup exported + Unable to export the Platoon backup Language English 한국어 @@ -144,6 +145,7 @@ Note deleted Export weekly CSV Weekly CSV exported + Unable to export weekly CSV Export all weekly task tables No weekly task tables are available to export. Settings diff --git a/app/src/test/java/dev/gf2log/app/OptionsBackupFailureMessageTest.kt b/app/src/test/java/dev/gf2log/app/OptionsBackupFailureMessageTest.kt index e9400e9..2bd4901 100644 --- a/app/src/test/java/dev/gf2log/app/OptionsBackupFailureMessageTest.kt +++ b/app/src/test/java/dev/gf2log/app/OptionsBackupFailureMessageTest.kt @@ -31,4 +31,10 @@ class OptionsBackupFailureMessageTest { fullBackupRestoreFailureMessage(IllegalArgumentException("local restore state")), ) } + + @Test + fun `export failures name the backup kind that failed`() { + assertEquals(R.string.full_backup_export_failed, backupExportFailureMessage(complete = true)) + assertEquals(R.string.status_backup_export_failed, backupExportFailureMessage(complete = false)) + } } From 50f6c36a1bdd99d3f1f7f68f02d1d1a394e1c3da Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:02:30 +0900 Subject: [PATCH 093/125] fix: give weekly evidence counts their own line instead of a trailing label --- app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt | 6 ++++-- app/src/main/res/values-ko/strings.xml | 4 ++-- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 8177c14..b224df9 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -442,8 +442,10 @@ class WeeklyReportActivity : BaseActivity() { health.unknownMetrics, ), icon = if (health.isComplete) R.drawable.ic_check_circle else R.drawable.ic_warning, - trailing = getString(R.string.how_it_works), - ) { WeeklyDialogs.showGuide(this@WeeklyReportActivity, report.isGunsmokeWeek) }, lp()) + ) { WeeklyDialogs.showGuide(this@WeeklyReportActivity, report.isGunsmokeWeek) }.apply { + // The chevron carries the guide affordance so the counts keep the full row width. + contentDescription = "$contentDescription. ${getString(R.string.how_it_works)}" + }, lp()) } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c037193..582ec81 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -461,7 +461,7 @@ 수정됨 모든 값 확정 출석과 일일 과업: ✓ 함, × 안 함 확정, - 관측 없음, ? 판단 불가. - 관측 %1$d/%2$d일 · 확정 %3$d · 최솟값 %4$d · 미확인 %5$d + 관측 %1$d/%2$d일\n확정 %3$d · 최솟값 %4$d · 미확인 %5$d 일부 값은 최솟값이거나 미확인 알 수 없음 이번 주로 @@ -480,7 +480,7 @@ 게임 일자 비고를 입력하세요. 과 - 근거: 관측 %1$d/%2$d일 · 확정 %3$d · 최솟값 %4$d · 미확인 %5$d + 근거: 관측 %1$d/%2$d일\n확정 %3$d · 최솟값 %4$d · 미확인 %5$d %1$d위 주 선택 민감한 정보입니다. 기본값은 꺼짐입니다. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97f66b2..630ea2f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -467,7 +467,7 @@ Edited All values confirmed Login and Daily Patrol: ✓ yes, × confirmed no, - not observed, ? undetermined. - Observed %1$d/%2$d days · exact %3$d · minimum %4$d · unknown %5$d + Observed %1$d/%2$d days\nExact %3$d · minimum %4$d · unknown %5$d Some values are minimums or unknown unknown This week From 8c747f85b952657cc3dde82366ab3f5ba2e616a2 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:02:30 +0900 Subject: [PATCH 094/125] fix: align the add-profile action with the CSV destination choices --- app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt index d2dffe8..7e52d37 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt @@ -3,6 +3,7 @@ package dev.gf2log.app import android.app.Activity import android.app.AlertDialog import android.text.InputType +import android.view.Gravity import android.view.View import android.widget.LinearLayout import android.widget.RadioButton @@ -18,6 +19,7 @@ import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.LabeledField import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP import dev.gf2log.app.ui.button import dev.gf2log.app.ui.dp import dev.gf2log.app.ui.lp @@ -46,12 +48,13 @@ internal object PlatoonCsvImportPrompt { } else { addView(choices, activity.lp(top = 8)) } + // Start-aligned and content-sized so the plus icon sits beside its label like a choice row. addView(activity.button(activity.getString(R.string.add_new_platoon_profile), Controls.Role.TERTIARY, R.drawable.ic_add) { showCreate(activity) { created -> dialog.dismiss() showSelector(activity, created.storageId, onSelected) } - }, activity.lp(top = 8)) + }.apply { gravity = Gravity.START or Gravity.CENTER_VERTICAL }, activity.lp(WRAP, WRAP, top = 8)) } dialog = Dialogs.show( Dialogs.builder(activity) From c74949b04a997a2d3e9a4bd2a434933c3714d0d6 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:02:44 +0900 Subject: [PATCH 095/125] fix: leave member details only after the profile save succeeds --- .../java/dev/gf2log/app/MemberDetailActivity.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index b1356bb..b7111ff 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -231,10 +231,8 @@ class MemberDetailActivity : BaseActivity() { .setTitle(R.string.unsaved_changes_title) .setMessage(R.string.unsaved_changes_message) .setNegativeButton(R.string.discard) { _, _ -> finish() } - .setPositiveButton(R.string.save_member) { _, _ -> - saveProfile() - finish() - }, + // Leave only once the save lands; a failure keeps the screen and its edits. + .setPositiveButton(R.string.save_member) { _, _ -> saveProfile(thenFinish = true) }, ) } @@ -245,12 +243,14 @@ class MemberDetailActivity : BaseActivity() { nickname.text.isNotBlank() } - private fun saveProfile() { + private fun saveProfile(thenFinish: Boolean = false) { + if (work.isBusy(SAVE)) return val name = nickname.text.toString() val noteText = note.text.toString() work.run(key = SAVE, task = { repository.updateMember(uid, name, noteText) }) { result -> - Feedback.show(this, if (result.getOrDefault(false)) R.string.saved else R.string.save_failed) - load() + val saved = result.getOrDefault(false) + Feedback.show(this, if (saved) R.string.saved else R.string.save_failed) + if (saved && thenFinish) finish() else load() } updateSaveState() } From 883f850bef3d16fcd3813ef0f51da1b7310aec9e Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:03:10 +0900 Subject: [PATCH 096/125] fix: ignore repeated submissions while history, undo, and delete writes run --- app/src/main/java/dev/gf2log/app/CsvImportFlow.kt | 10 +++++++--- app/src/main/java/dev/gf2log/app/MainActivity.kt | 12 +++++++++--- .../main/java/dev/gf2log/app/MemberDetailActivity.kt | 11 +++++++---- app/src/main/java/dev/gf2log/app/OptionsActivity.kt | 3 ++- .../gf2log/app/WeeklyTableHistoryPreviewActivity.kt | 6 ++++-- 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt index 9288333..348bc21 100644 --- a/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt +++ b/app/src/main/java/dev/gf2log/app/CsvImportFlow.kt @@ -80,14 +80,17 @@ internal class CsvImportFlow( Feedback.show(activity, R.string.no_platoon_detected_detail, long = true) return@whenStopped } - work.run(task = { CsvImportCheckpointManager(activity, scope).canUndo() }) { result -> + // One undo at a time: a second restore would run against the already-restored state. + if (work.isBusy(UNDO)) return@whenStopped + work.run(key = UNDO, task = { CsvImportCheckpointManager(activity, scope).canUndo() }) { result -> if (!result.getOrDefault(false)) { Feedback.show(activity, R.string.no_csv_import_checkpoint) return@run } - Dialogs.confirm(activity, R.string.undo_last_csv_import, R.string.undo_last_csv_import_warning, R.string.undo) { + Dialogs.confirm(activity, R.string.undo_last_csv_import, R.string.undo_last_csv_import_warning, R.string.undo) confirm@{ + if (work.isBusy(UNDO)) return@confirm onProgress(activity.getString(R.string.csv_import_undoing)) - work.run(task = { CsvImportCheckpointManager(activity, scope).restore() }) { restore -> + work.run(key = UNDO, task = { CsvImportCheckpointManager(activity, scope).restore() }) { restore -> onProgress(null) Feedback.show( activity, @@ -310,6 +313,7 @@ internal class CsvImportFlow( ) private companion object { + const val UNDO = "csv_import.undo" const val MAX_FILES = 64 const val MAX_BYTES = 16L * 1024 * 1024 const val STATE_TARGET = "csv_import.target" diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 6e3885b..fd5ee01 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -695,8 +695,9 @@ class MainActivity : BaseActivity() { private fun saveSelected() { val source = historyStore ?: return val target = savedStore ?: return + if (work.isBusy(HISTORY_WRITE)) return val ids = selectedIds.toList() - work.run(task = { target.saveFrom(source, ids) }) { result -> + work.run(key = HISTORY_WRITE, task = { target.saveFrom(source, ids) }) { result -> val saveResult = result.getOrNull() Feedback.show( this, @@ -728,8 +729,12 @@ class MainActivity : BaseActivity() { resources.getQuantityString(R.plurals.delete_history_message, ids.size, ids.size), getString(R.string.delete), destructive = true, - ) { - work.run(task = { (if (fromSaved) savedStore?.delete(ids) else historyStore?.delete(ids)) ?: 0 }) { result -> + ) confirm@{ + if (work.isBusy(HISTORY_WRITE)) return@confirm + work.run( + key = HISTORY_WRITE, + task = { (if (fromSaved) savedStore?.delete(ids) else historyStore?.delete(ids)) ?: 0 }, + ) { result -> val deleted = result.getOrDefault(0) Feedback.show( this, @@ -751,6 +756,7 @@ class MainActivity : BaseActivity() { const val HISTORY = "history" const val SUMMARY = "summary" const val UNDO = "undo" + const val HISTORY_WRITE = "history_write" const val RECENT = "recent" const val SAVED = "saved" const val COLLAPSED_COUNT = 20 diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index b7111ff..95c1da8 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -256,8 +256,9 @@ class MemberDetailActivity : BaseActivity() { } private fun confirmMemberDeletion() { - Dialogs.confirm(this, R.string.delete_member, R.string.delete_member_warning, R.string.delete, destructive = true) { - work.run(task = { repository.deleteMember(uid) }) { result -> + Dialogs.confirm(this, R.string.delete_member, R.string.delete_member_warning, R.string.delete, destructive = true) confirm@{ + if (work.isBusy(DELETE)) return@confirm + work.run(key = DELETE, task = { repository.deleteMember(uid) }) { result -> if (result.getOrDefault(false)) { Feedback.show(this, R.string.member_deleted) finish() @@ -342,8 +343,9 @@ class MemberDetailActivity : BaseActivity() { R.string.delete_membership_period_warning, R.string.delete, destructive = true, - ) { - work.run(task = { repository.deleteMembershipPeriod(period.id) }) { result -> + ) confirm@{ + if (work.isBusy(DELETE)) return@confirm + work.run(key = DELETE, task = { repository.deleteMembershipPeriod(period.id) }) { result -> if (result.getOrDefault(false)) { editor.dismiss() Feedback.show(this, R.string.membership_period_deleted) @@ -440,6 +442,7 @@ class MemberDetailActivity : BaseActivity() { const val EXTRA_UID = "uid" private const val LOAD = "load" private const val SAVE = "save" + private const val DELETE = "delete" private const val STATE_INPUTS_INITIALIZED = "member.inputs_initialized" } } diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 8c7efe0..d2117d7 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -427,7 +427,8 @@ class OptionsActivity : BaseActivity() { dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.apply { setTextColor(getColor(R.color.destructive_action)) setOnClickListener { - work.run(task = { DiscordWebhookSecretStore(this@OptionsActivity).clear() }) { result -> + if (work.isBusy(WEBHOOK)) return@setOnClickListener + work.run(key = WEBHOOK, task = { DiscordWebhookSecretStore(this@OptionsActivity).clear() }) { result -> dialog.dismiss() Feedback.show( this@OptionsActivity, diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt index b781167..767a9ad 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTableHistoryPreviewActivity.kt @@ -106,9 +106,10 @@ class WeeklyTableHistoryPreviewActivity : BaseActivity() { R.string.weekly_table_history_restore, R.string.weekly_table_history_restore_warning, R.string.weekly_table_history_restore, - ) { + ) confirm@{ + if (work.isBusy(RESTORE)) return@confirm restoreButton?.isEnabled = false - work.run(task = { repository.restoreWeeklyReportHistory(historyId, periodStart) }) { result -> + work.run(key = RESTORE, task = { repository.restoreWeeklyReportHistory(historyId, periodStart) }) { result -> if (result.getOrDefault(false)) { Feedback.show(this, R.string.weekly_table_history_restored) startActivity( @@ -161,5 +162,6 @@ class WeeklyTableHistoryPreviewActivity : BaseActivity() { companion object { const val EXTRA_HISTORY_ID = "weekly_history_id" const val EXTRA_RECORDED_AT = "weekly_history_recorded_at" + private const val RESTORE = "restore" } } From 7181e0e6d4579dca942f190781b9c070329e8287 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:03:28 +0900 Subject: [PATCH 097/125] fix: keep the chosen member-order sort across recreation --- .../main/java/dev/gf2log/app/MemberOrderActivity.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index aa9a48e..864e01b 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -74,6 +74,10 @@ class MemberOrderActivity : BaseActivity() { } repository = PlatoonRepository(this, scope) preferences = MemberOrderPreferences(this, scope.storageId) + savedInstanceState?.let { state -> + sortField = MemberSortField.entries.getOrElse(state.getInt(STATE_SORT_FIELD)) { sortField } + sortDirection = MemberSortDirection.entries.getOrElse(state.getInt(STATE_SORT_DIRECTION)) { sortDirection } + } val screen = Screen(this) screen.topBar .setNavigation(::finish) @@ -98,6 +102,12 @@ class MemberOrderActivity : BaseActivity() { if (!profileBinding.isCurrent(this)) finish() } + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt(STATE_SORT_FIELD, sortField.ordinal) + outState.putInt(STATE_SORT_DIRECTION, sortDirection.ordinal) + super.onSaveInstanceState(outState) + } + private fun buildHeader(): View = column { addView(text(getString(R.string.member_order_description), TextRole.SECONDARY), lp(top = 4)) fieldButton = choiceChip { chooseField() } @@ -333,5 +343,7 @@ class MemberOrderActivity : BaseActivity() { private companion object { val POSITION_PAYLOAD = Any() + const val STATE_SORT_FIELD = "member_order.sort_field" + const val STATE_SORT_DIRECTION = "member_order.sort_direction" } } From abb573a63de3ebc41967787fa21af33f205af62e Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:03:38 +0900 Subject: [PATCH 098/125] fix: close packet details when the active Platoon changes --- app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index 519e4df..1c50eab 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -86,6 +86,11 @@ class PacketHistoryActivity : BaseActivity() { } } + override fun onScreenResumed() { + // The entry belongs to the Platoon it was opened from; a switch elsewhere closes it. + if (PlatoonProfileRegistry(this).active()?.storageId != intent.getStringExtra(EXTRA_STORAGE_ID)) finish() + } + override fun onSaveInstanceState(outState: Bundle) { outState.putBoolean(STATE_RAW, showingRaw) super.onSaveInstanceState(outState) From 9cd51045b89cfdc39bfbf64b8dd1bd1acc626ab4 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:04:13 +0900 Subject: [PATCH 099/125] fix: format UIDs and labeled descriptions through string placeholders --- app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt | 4 ++-- app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt | 7 ++++--- app/src/main/java/dev/gf2log/app/PlatoonActivity.kt | 8 ++++---- .../java/dev/gf2log/app/SnapshotComparisonActivity.kt | 6 +++--- app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt | 7 ++++--- app/src/main/java/dev/gf2log/app/WeeklyTable.kt | 4 ++-- app/src/main/java/dev/gf2log/app/ui/Labels.kt | 7 +++++++ app/src/main/res/values-ko/strings.xml | 3 +++ app/src/main/res/values/strings.xml | 3 +++ 9 files changed, 32 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 95c1da8..d8c1473 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -165,7 +165,7 @@ class MemberDetailActivity : BaseActivity() { return } val status = loaded.status - screen.topBar.setTitle(status.name, "#${status.uid}") + screen.topBar.setTitle(status.name, Labels.uid(this, status.uid)) statusPanel.addView(row { addView(text(getString(R.string.member_since, firstSeen(status, loaded.zone)), TextRole.SECONDARY), lp(width = 0, weight = 1f)) addView(badge( @@ -188,7 +188,7 @@ class MemberDetailActivity : BaseActivity() { pair.forEachIndexed { index, (label, value) -> addView(column { isFocusable = true - contentDescription = "$label: $value" + contentDescription = Labels.labeled(context, label, value) addView(text(label, TextRole.CAPTION) { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp()) addView(text(value, TextRole.SUBTITLE) { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(top = 2)) }, lp(width = 0, weight = 1f, start = if (index == 0) 0 else 12)) diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index 864e01b..0ff93b5 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -28,6 +28,7 @@ import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.MATCH import dev.gf2log.app.ui.MenuEntry import dev.gf2log.app.ui.Screen @@ -184,11 +185,11 @@ class MemberOrderActivity : BaseActivity() { private fun updateSortLabels() { fieldButton.text = getString(fieldLabel(sortField)) - fieldButton.contentDescription = getString(R.string.sort_criterion) + ": " + fieldButton.text + fieldButton.contentDescription = Labels.labeled(this, getString(R.string.sort_criterion), fieldButton.text) directionButton.text = getString( if (sortDirection == MemberSortDirection.ASCENDING) R.string.ascending else R.string.descending, ) - directionButton.contentDescription = getString(R.string.sort_direction) + ": " + directionButton.text + directionButton.contentDescription = Labels.labeled(this, getString(R.string.sort_direction), directionButton.text) } private fun fieldLabel(field: MemberSortField) = when (field) { @@ -286,7 +287,7 @@ class MemberOrderActivity : BaseActivity() { fun bind(member: MemberStatus, index: Int) { position.text = (index + 1).toString() name.text = member.name - detail.text = "#${member.uid}" + detail.text = Labels.uid(this@MemberOrderActivity, member.uid) itemView.contentDescription = getString(R.string.member_order_position, index + 1, members.size, member.name) handle.contentDescription = getString(R.string.drag_member, member.name) ViewCompat.removeAccessibilityAction(itemView, R.id.action_move_up) diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index f39ab35..c506cdf 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -229,7 +229,7 @@ class PlatoonActivity : BaseActivity() { tiles.forEachIndexed { index, (label, value) -> summary.addView(panel(padding = 12) { isFocusable = true - contentDescription = "$label: $value" + contentDescription = Labels.labeled(context, label, value) addView(text(label, TextRole.CAPTION) { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO maxLines = 1 @@ -290,7 +290,7 @@ class PlatoonActivity : BaseActivity() { private fun updateSortLabel() { sortButton.text = getString(sort.label) - sortButton.contentDescription = getString(R.string.sort_by) + ": " + getString(sort.label) + sortButton.contentDescription = Labels.labeled(this, getString(R.string.sort_by), getString(sort.label)) } private fun setSelecting(enabled: Boolean) { @@ -487,7 +487,7 @@ class PlatoonActivity : BaseActivity() { status.text = getString(if (member.isActive) R.string.active_member else R.string.departed_member) status.visibility = if (member.isActive) View.GONE else View.VISIBLE detail.text = buildList { - add("#${member.uid}") + add(Labels.uid(this@PlatoonActivity, member.uid)) add(getString(R.string.level_short, latest?.level ?: member.level)) latest?.lastLogin?.takeIf { it > 0 }?.let { add(getString( @@ -503,7 +503,7 @@ class PlatoonActivity : BaseActivity() { member.name, status.text, detail.text, - getString(R.string.merit_this_week) + " " + merit.text, + Labels.labeled(this@PlatoonActivity, getString(R.string.merit_this_week), merit.text), ).joinToString(". ") row.setOnClickListener { if (selecting) { diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index a188d9e..dbf44d6 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -92,8 +92,8 @@ class SnapshotComparisonActivity : BaseActivity() { TextRole.CAPTION, ), lp(top = 4)) }, lp(top = 4)) - section(getString(R.string.joined_members), result.joined.size, result.joined.map { it.name to "#${it.uid}" }) - section(getString(R.string.departed_members), result.left.size, result.left.map { it.name to "#${it.uid}" }) + 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()) { content.addView(text(getString(R.string.none), TextRole.SECONDARY), lp(start = 4)) @@ -110,7 +110,7 @@ class SnapshotComparisonActivity : BaseActivity() { maxLines = 1 ellipsize = TextUtils.TruncateAt.END }, lp(width = 0, weight = 1f)) - addView(text("#${change.uid}", TextRole.CAPTION), lp(WRAP, WRAP, start = 8)) + addView(text(Labels.uid(context, change.uid), TextRole.CAPTION), lp(WRAP, WRAP, start = 8)) }, lp()) addView(row { listOf( diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index b224df9..9f2eb12 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -500,7 +500,8 @@ class WeeklyReportActivity : BaseActivity() { val joined = event.type == MemberEventType.JOINED || event.type == MemberEventType.REJOINED val label = getString(if (joined) R.string.join_label else R.string.withdraw_label) val name = next.namesByUid[event.uid].orEmpty().ifBlank { event.note } - val identity = name.takeIf(String::isNotBlank)?.let { "$it (#${event.uid})" } ?: "#${event.uid}" + val identity = name.takeIf(String::isNotBlank)?.let { getString(R.string.member_name_uid, it, event.uid) } + ?: Labels.uid(this, event.uid) val time = MembershipEventPresentation.timePrefix(event, next.zone) return row { minimumHeight = dp(40) @@ -578,7 +579,7 @@ class WeeklyReportActivity : BaseActivity() { private fun updateNoteDay() { val day = noteDay ?: return noteDayButton.text = Labels.dayHeader(this, day) - noteDayButton.contentDescription = getString(R.string.note_day) + ": " + noteDayButton.text + noteDayButton.contentDescription = Labels.labeled(this, getString(R.string.note_day), noteDayButton.text) } // endregion @@ -738,7 +739,7 @@ class WeeklyReportActivity : BaseActivity() { val existing = report.members.flatMap { member -> member.days.mapNotNull { it.manualOverride } } when (val merged = WeeklyOverrideEditing.merge(report.periodStart, report.isGunsmokeWeek, existing, edits.values)) { is WeeklyOverrideEditing.Result.Invalid -> { - val name = current.namesByUid[merged.uid] ?: "#${merged.uid}" + val name = current.namesByUid[merged.uid] ?: Labels.uid(this, merged.uid) Feedback.show( this, getString(R.string.invalid_weekly_edit_cell, name, Labels.dayHeader(this, merged.gameDay)), diff --git a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt index 9e504db..a43b6c5 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyTable.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyTable.kt @@ -176,7 +176,7 @@ internal class WeeklyTable( onCell: (WeeklyReportBuilder.DayCell?) -> Unit, ) { name.text = member.name - detail.text = "#${member.uid}" + detail.text = Labels.uid(context, member.uid) rank.text = rankValue?.toString().orEmpty() // Only the podium carries the accent; the rest of the ranking stays quiet. val podium = rankValue != null && rankValue <= PODIUM @@ -185,7 +185,7 @@ internal class WeeklyTable( frozen.contentDescription = listOfNotNull( rankValue?.let { context.getString(R.string.rank_description, it) }, member.name, - "#${member.uid}", + Labels.uid(context, member.uid), ).joinToString(". ") frozen.isFocusable = true member.days.forEachIndexed { index, cell -> 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 e861645..8f91e18 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Labels.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Labels.kt @@ -21,6 +21,13 @@ internal object Labels { val FILE_STAMP: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") val FILE_DATE: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE + /** "#1234": member UIDs read the same everywhere and stay out of string concatenation. */ + fun uid(context: Context, uid: Long): String = context.getString(R.string.member_uid, uid) + + /** "Label: value" for spoken descriptions, with locale-controlled punctuation. */ + fun labeled(context: Context, label: CharSequence, value: CharSequence): String = + context.getString(R.string.labeled_value, label, value) + fun format(formatter: DateTimeFormatter, instant: Instant, zone: ZoneId): String = formatter.format(instant.atZone(zone)) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 582ec81..c515663 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -492,6 +492,9 @@ %1$s – %2$s 초기화 05:00 · %1$s %1$s, %2$s + %1$s: %2$s + #%1$d + %1$s (#%2$d) %1$s · %2$s 날짜 칸을 눌러 값을 바꾸세요. 수정한 칸은 저장할 때까지 강조됩니다. 기록된 이전 버전의 표입니다. 복원하면 새 근거로 표가 바뀔 때까지 이 버전을 표시합니다. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 630ea2f..d54d6b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -498,6 +498,9 @@ %1$s – %2$s Reset 05:00 · %1$s %1$s, %2$s + %1$s: %2$s + #%1$d + %1$s (#%2$d) %1$s · %2$s Tap a day to change its values. Edited cells are highlighted until you save. This is a recorded version of the table. Restoring shows it until new evidence changes the table. From 907ba0ebdb84a38da38ddabb06f16cc8800c83a3 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:05:09 +0900 Subject: [PATCH 100/125] refactor: share inline error and clipboard helpers across screens --- .../dev/gf2log/app/MemberDetailActivity.kt | 25 ++++++------------- .../java/dev/gf2log/app/OptionsActivity.kt | 17 ++++--------- .../dev/gf2log/app/PacketHistoryActivity.kt | 6 +---- .../java/dev/gf2log/app/PlatoonActivity.kt | 14 ++++------- .../dev/gf2log/app/PlatoonCsvImportPrompt.kt | 11 +++----- .../gf2log/app/SnapshotComparisonActivity.kt | 10 ++++---- .../main/java/dev/gf2log/app/WeeklyDialogs.kt | 13 ++++------ .../dev/gf2log/app/WeeklyReportActivity.kt | 7 +----- .../main/java/dev/gf2log/app/ui/Dialogs.kt | 7 ++++++ app/src/main/java/dev/gf2log/app/ui/Views.kt | 12 +++++++++ 10 files changed, 53 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index d8c1473..0288e46 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -36,6 +36,8 @@ import dev.gf2log.app.ui.navigationRow import dev.gf2log.app.ui.panel import dev.gf2log.app.ui.row import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.showMessage import dev.gf2log.app.ui.text import dev.gf2log.app.ui.textField import java.time.Instant @@ -291,7 +293,7 @@ class MemberDetailActivity : BaseActivity() { zone = zone, ) val noteInput = textField(getString(R.string.membership_note_hint), singleLine = false).apply { setText(period.note) } - val error = errorText() + val error = errorLine() val canDelete = status.membershipPeriods.size > 1 val body = Dialogs.content(this) { addView(joined, lp()) @@ -315,7 +317,7 @@ class MemberDetailActivity : BaseActivity() { val joinedBoundary = joined.boundary val leftBoundary = left.boundary if (joinedBoundary == null || !isValidMembershipRange(joinedBoundary, leftBoundary)) { - error.show(R.string.invalid_date) + error.showMessage(getString(R.string.invalid_date)) return@setOnClickListener } button.isEnabled = false @@ -329,7 +331,7 @@ class MemberDetailActivity : BaseActivity() { Feedback.show(this, R.string.saved) load() } else { - error.show(R.string.membership_period_save_failed) + error.showMessage(getString(R.string.membership_period_save_failed)) } } } @@ -362,7 +364,7 @@ class MemberDetailActivity : BaseActivity() { 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) - val error = errorText() + val error = errorLine() val body = Dialogs.content(this) { addView(joined, lp()) addView(withdrew, lp(top = 12)) @@ -380,7 +382,7 @@ class MemberDetailActivity : BaseActivity() { val joinedBoundary = joined.boundary val withdrewBoundary = withdrew.boundary if (joinedBoundary == null || !isValidMembershipRange(joinedBoundary, withdrewBoundary)) { - error.show(R.string.invalid_date) + error.showMessage(getString(R.string.invalid_date)) return@setOnClickListener } button.isEnabled = false @@ -393,24 +395,13 @@ class MemberDetailActivity : BaseActivity() { dialog.dismiss() load() } else { - error.show(R.string.membership_period_overlap) + error.showMessage(getString(R.string.membership_period_overlap)) } } } } } - private fun errorText() = text(null, TextRole.CAPTION) { - setTextColor(getColor(R.color.destructive_action)) - accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE - visibility = View.GONE - } - - private fun android.widget.TextView.show(message: Int) { - text = getString(message) - visibility = View.VISIBLE - } - private fun boundaryText(date: LocalDate?, instant: Instant?, timeKnown: Boolean, zone: ZoneId): String { val displayDate = date ?: instant?.atZone(zone)?.toLocalDate() ?: return getString(R.string.unknown) return if (timeKnown && instant != null) { diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index d2117d7..3abf198 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -1,8 +1,6 @@ package dev.gf2log.app import android.app.AlertDialog -import android.content.ClipData -import android.content.ClipboardManager import android.content.Intent import android.content.pm.PackageManager import android.net.Uri @@ -42,6 +40,8 @@ import dev.gf2log.app.ui.navigationRow import dev.gf2log.app.ui.panel import dev.gf2log.app.ui.sectionHeader import dev.gf2log.app.ui.switchRow +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.showMessage import dev.gf2log.app.ui.text import dev.gf2log.app.ui.textField import dev.gf2log.protocol.PayloadCatalog @@ -394,11 +394,7 @@ class OptionsActivity : BaseActivity() { id = R.id.webhook_input, inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI, ) - val error = text(null, TextRole.CAPTION) { - setTextColor(getColor(R.color.destructive_action)) - accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE - visibility = View.GONE - } + val error = errorLine() val builder = Dialogs.builder(this) .setTitle(R.string.discord_webhook) .setView(Dialogs.content(this) { @@ -419,8 +415,7 @@ class OptionsActivity : BaseActivity() { Feedback.show(this, R.string.discord_webhook_saved) renderWebhookRow(container) } else { - error.text = getString(R.string.discord_webhook_invalid) - error.visibility = View.VISIBLE + error.showMessage(getString(R.string.discord_webhook_invalid)) } } } @@ -464,9 +459,7 @@ class OptionsActivity : BaseActivity() { .setPositiveButton(R.string.done, null) if (diagnostics != null) { builder.setNeutralButton(R.string.copy) { _, _ -> - getSystemService(ClipboardManager::class.java) - .setPrimaryClip(ClipData.newPlainText(getString(R.string.last_capture_diagnostics), message)) - Feedback.show(this, R.string.copied) + Feedback.copy(this, getString(R.string.last_capture_diagnostics), message, R.string.copied) } } Dialogs.show(builder) diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index 1c50eab..d27a9e2 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -1,7 +1,5 @@ package dev.gf2log.app -import android.content.ClipData -import android.content.ClipboardManager import android.content.Intent import android.graphics.Typeface import android.os.Bundle @@ -158,9 +156,7 @@ class PacketHistoryActivity : BaseActivity() { } private fun copy(content: String) { - getSystemService(ClipboardManager::class.java) - .setPrimaryClip(ClipData.newPlainText(getString(R.string.clipboard_label), content)) - Feedback.show(this, R.string.status_packet_copied) + Feedback.copy(this, getString(R.string.clipboard_label), content, R.string.status_packet_copied) } private fun confirmSend(content: String) { diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index c506cdf..d7723e3 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -50,6 +50,8 @@ import dev.gf2log.app.ui.lp import dev.gf2log.app.ui.panel import dev.gf2log.app.ui.row import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.showMessage import dev.gf2log.app.ui.text import dev.gf2log.app.ui.textField import java.time.Instant @@ -364,11 +366,7 @@ class PlatoonActivity : BaseActivity() { getString(R.string.membership_note_hint), textField(getString(R.string.membership_note_hint), singleLine = false), ) - val error = text(null, TextRole.CAPTION) { - setTextColor(getColor(R.color.destructive_action)) - accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE - visibility = View.GONE - } + val error = errorLine() val content = Dialogs.content(this) { listOf(uid, nickname, joined, withdrew, note, error).forEachIndexed { index, view -> addView(view, lp(top = if (index == 0) 0 else 12)) @@ -395,8 +393,7 @@ class PlatoonActivity : BaseActivity() { else -> null } if (problem != null) { - error.text = getString(problem) - error.visibility = View.VISIBLE + error.showMessage(getString(problem)) return@setOnClickListener } button.isEnabled = false @@ -418,8 +415,7 @@ class PlatoonActivity : BaseActivity() { Feedback.show(this, R.string.saved) load() } else { - error.text = getString(R.string.invalid_withdrawn_member) - error.visibility = View.VISIBLE + error.showMessage(getString(R.string.invalid_withdrawn_member)) } } } diff --git a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt index 7e52d37..59cf2ae 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonCsvImportPrompt.kt @@ -23,6 +23,8 @@ import dev.gf2log.app.ui.WRAP import dev.gf2log.app.ui.button import dev.gf2log.app.ui.dp import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.showMessage import dev.gf2log.app.ui.text import dev.gf2log.app.ui.textField import java.util.concurrent.Executors @@ -89,11 +91,7 @@ internal object PlatoonCsvImportPrompt { activity.textField(activity.getString(R.string.platoon_id_hint), inputType = InputType.TYPE_CLASS_NUMBER), ) val servers = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL } - val error = activity.text(null, TextRole.CAPTION) { - setTextColor(activity.getColor(R.color.destructive_action)) - accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE - visibility = View.GONE - } + val error = activity.errorLine() lateinit var dialog: AlertDialog fun validId(): Long? = platoonId.input.text.toString().toLongOrNull()?.takeIf { it in 1L..UInt.MAX_VALUE.toLong() } fun updateEnabled() { @@ -160,8 +158,7 @@ internal object PlatoonCsvImportPrompt { onCreated(it) }, onFailure = { - error.text = activity.getString(R.string.unable_to_add_platoon_profile) - error.visibility = View.VISIBLE + error.showMessage(activity.getString(R.string.unable_to_add_platoon_profile)) }, ) } diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index dbf44d6..8e990a7 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -1,7 +1,5 @@ package dev.gf2log.app -import android.content.ClipData -import android.content.ClipboardManager import android.os.Bundle import android.text.TextUtils import android.view.View @@ -72,10 +70,12 @@ class SnapshotComparisonActivity : BaseActivity() { return } screen.topBar.setActions(listOf(BarAction(R.drawable.ic_copy, getString(R.string.copy_csv)) { - getSystemService(ClipboardManager::class.java).setPrimaryClip( - ClipData.newPlainText(getString(R.string.snapshot_csv_clipboard_label), SnapshotComparisonCsv.format(result)), + Feedback.copy( + this, + getString(R.string.snapshot_csv_clipboard_label), + SnapshotComparisonCsv.format(result), + R.string.snapshot_csv_copied, ) - Feedback.show(this, R.string.snapshot_csv_copied) })) content.addView(panel { addView(text(getString(R.string.comparison_window), TextRole.LABEL), lp()) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt index cd4e765..d8c80c8 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -25,6 +25,8 @@ import dev.gf2log.app.ui.dp import dev.gf2log.app.ui.lp import dev.gf2log.app.ui.row import dev.gf2log.app.ui.switchRow +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.showMessage import dev.gf2log.app.ui.text import dev.gf2log.app.ui.textField @@ -102,11 +104,7 @@ internal object WeeklyDialogs { val patrolInitial = current?.dailyPatrol ?: Choice.of(cell.dailyPatrol) var login = loginInitial var patrol = patrolInitial - val error = activity.text(null, TextRole.CAPTION) { - setTextColor(activity.getColor(R.color.destructive_action)) - accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE - visibility = View.GONE - } + val error = activity.errorLine() val clear = activity.button(activity.getString(R.string.clear_manual_values), Controls.Role.DESTRUCTIVE_TEXT) {} val content = Dialogs.content(activity) { addView(activity.text(activity.getString(R.string.manual_edit_hint), TextRole.SECONDARY), activity.lp()) @@ -140,10 +138,9 @@ internal object WeeklyDialogs { listOf(edit), ) if (probe is WeeklyOverrideEditing.Result.Invalid) { - error.text = activity.getString( + error.showMessage(activity.getString( if (probe.field == WeeklyOverrideEditing.Field.ATTEMPTS) R.string.invalid_attempts else R.string.invalid_counter, - ) - error.visibility = View.VISIBLE + )) return } dialog.dismiss() diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 9f2eb12..2a10fb1 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -1,8 +1,6 @@ package dev.gf2log.app import android.app.DatePickerDialog -import android.content.ClipData -import android.content.ClipboardManager import android.content.Intent import android.os.Bundle import android.text.TextUtils @@ -787,10 +785,7 @@ class WeeklyReportActivity : BaseActivity() { private fun copyWeek() { val report = model?.report ?: return - getSystemService(ClipboardManager::class.java).setPrimaryClip( - ClipData.newPlainText(getString(R.string.weekly_csv_clipboard_label), WeeklyReportCsv.format(report)), - ) - Feedback.show(this, R.string.weekly_csv_copied) + Feedback.copy(this, getString(R.string.weekly_csv_clipboard_label), WeeklyReportCsv.format(report), R.string.weekly_csv_copied) } private fun share() { 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 1bf594f..5c241e1 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt @@ -152,4 +152,11 @@ internal object Feedback { fun show(context: Context, @StringRes message: Int, long: Boolean = false) { show(context, context.getString(message), long) } + + /** Copies plain [value] under the clipboard [label] and confirms with [confirmation]. */ + fun copy(context: Context, label: CharSequence, value: CharSequence, @StringRes confirmation: Int) { + context.getSystemService(android.content.ClipboardManager::class.java) + .setPrimaryClip(android.content.ClipData.newPlainText(label, value)) + show(context, confirmation) + } } 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 9d5f742..f15a96f 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Views.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -218,6 +218,18 @@ internal fun Context.searchField(hintText: CharSequence, @IdRes id: Int, query: } } +/** Hidden inline error for dialogs and forms; [showMessage] reveals it and announces the change. */ +internal fun Context.errorLine(): TextView = text(null, TextRole.CAPTION) { + setTextColor(getColor(R.color.destructive_action)) + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + visibility = View.GONE +} + +internal fun TextView.showMessage(message: CharSequence) { + text = message + visibility = View.VISIBLE +} + /** Labeled input with an optional supporting or error line. */ internal class LabeledField( context: Context, From 4fd1c5b8b1b9b5b3dee62e8035044bfc425603f1 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:06:11 +0900 Subject: [PATCH 101/125] refactor: move the weekly edit draft into a testable session --- .../java/dev/gf2log/app/WeeklyEditSession.kt | 92 +++++++++++++++++++ .../dev/gf2log/app/WeeklyReportActivity.kt | 72 +++------------ .../dev/gf2log/app/WeeklyEditSessionTest.kt | 79 ++++++++++++++++ 3 files changed, 186 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/WeeklyEditSession.kt create mode 100644 app/src/test/java/dev/gf2log/app/WeeklyEditSessionTest.kt diff --git a/app/src/main/java/dev/gf2log/app/WeeklyEditSession.kt b/app/src/main/java/dev/gf2log/app/WeeklyEditSession.kt new file mode 100644 index 0000000..c49d89e --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyEditSession.kt @@ -0,0 +1,92 @@ +package dev.gf2log.app + +import android.os.Bundle +import dev.gf2log.app.management.WeeklyOverrideEditing.CellEdit +import dev.gf2log.app.management.WeeklyOverrideEditing.Choice +import java.time.LocalDate + +/** + * The unsaved manual edits of the weekly table. At most one week is edited at a time, and a draft + * belongs to the Platoon scope it was started in, so a recreated screen in another scope drops it. + */ +internal class WeeklyEditSession { + /** The week being edited, or null outside edit mode. */ + var period: LocalDate? = null + private set + private val edits = linkedMapOf, CellEdit>() + + val values: Collection get() = edits.values + val size: Int get() = edits.size + + fun isEmpty(): Boolean = edits.isEmpty() + + fun isEditing(periodStart: LocalDate): Boolean = period == periodStart + + fun start(periodStart: LocalDate) { + period = periodStart + edits.clear() + } + + fun finish() { + period = null + edits.clear() + } + + /** Drops a draft for another week than [periodStart], the week now shown. */ + fun retainFor(periodStart: LocalDate) { + if (period != null && period != periodStart) finish() + } + + operator fun get(uid: Long, gameDay: LocalDate): CellEdit? = edits[uid to gameDay] + + /** Records [edit]; an empty edit restores the cell. Ignored outside edit mode. */ + fun put(edit: CellEdit): Boolean { + if (period == null) return false + val key = edit.uid to edit.gameDay + if (edit.isEmpty) edits.remove(key) else edits[key] = edit + return true + } + + fun forMember(uid: Long): Map = + edits.values.filter { it.uid == uid }.associateBy(CellEdit::gameDay) + + fun save(state: Bundle, scopeId: String) { + val editing = period ?: return + state.putString(STATE_SCOPE, scopeId) + state.putLong(STATE_PERIOD, editing.toEpochDay()) + state.putParcelableArrayList(STATE_EDITS, ArrayList(edits.values.map(::encode))) + } + + fun restore(state: Bundle?, scopeId: String) { + if (state?.containsKey(STATE_PERIOD) != true || state.getString(STATE_SCOPE) != scopeId) return + start(LocalDate.ofEpochDay(state.getLong(STATE_PERIOD))) + @Suppress("DEPRECATION") + state.getParcelableArrayList(STATE_EDITS)?.map(::decode)?.forEach(::put) + } + + private fun encode(edit: CellEdit) = Bundle().apply { + putLong("uid", edit.uid) + putLong("day", edit.gameDay.toEpochDay()) + edit.merit?.let { putString("merit", it) } + edit.score?.let { putString("score", it) } + edit.attempts?.let { putString("attempts", it) } + edit.attended?.let { putString("attended", it.name) } + edit.dailyPatrol?.let { putString("patrol", it.name) } + } + + private fun decode(bundle: Bundle) = CellEdit( + uid = bundle.getLong("uid"), + gameDay = LocalDate.ofEpochDay(bundle.getLong("day")), + merit = bundle.getString("merit"), + score = bundle.getString("score"), + attempts = bundle.getString("attempts"), + attended = bundle.getString("attended")?.let(Choice::valueOf), + dailyPatrol = bundle.getString("patrol")?.let(Choice::valueOf), + ) + + private companion object { + const val STATE_PERIOD = "weekly.editing_period" + const val STATE_SCOPE = "weekly.editing_scope" + const val STATE_EDITS = "weekly.edits" + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 2a10fb1..465c1c1 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -98,8 +98,7 @@ class WeeklyReportActivity : BaseActivity() { /** One table per week kind, reused across renders so recycled rows and scroll position survive. */ private val tables = HashMap() private val horizontalSync = HorizontalSync() - private var editingPeriod: LocalDate? = null - private val edits = linkedMapOf, WeeklyOverrideEditing.CellEdit>() + private val edits = WeeklyEditSession() private var noteDay: LocalDate? = null private var pendingExport: String? = null private var pendingPng: File? = null @@ -202,11 +201,7 @@ class WeeklyReportActivity : BaseActivity() { } WeeklyPngExport.nameForState(cacheDir, pendingPng)?.let { outState.putString(STATE_PENDING_PNG_NAME, it) } pendingExport?.let { outState.putString(STATE_PENDING_EXPORT, it) } - editingPeriod?.let { period -> - outState.putString(STATE_EDITING_SCOPE, scope.storageId) - outState.putLong(STATE_EDITING_PERIOD, period.toEpochDay()) - outState.putParcelableArrayList(STATE_EDITS, ArrayList(edits.values.map(::encodeEdit))) - } + if (::scope.isInitialized) edits.save(outState, scope.storageId) noteDay?.let { outState.putLong(STATE_NOTE_DAY, it.toEpochDay()) } // The composer lives inside the RecyclerView, which does not save its children's state. if (::noteInput.isInitialized) outState.putString(STATE_NOTE_DRAFT, noteInput.text.toString()) @@ -223,13 +218,7 @@ class WeeklyReportActivity : BaseActivity() { ?: PlatoonPeriods.gameDay(Instant.now(), GameTimeZonePreferences.get(this, scope.storageId)), ) // Edits belong to one Platoon; a profile switch recreates this screen in another scope. - if (state?.containsKey(STATE_EDITING_PERIOD) == true && state.getString(STATE_EDITING_SCOPE) == scope.storageId) { - editingPeriod = LocalDate.ofEpochDay(state.getLong(STATE_EDITING_PERIOD)) - @Suppress("DEPRECATION") - state.getParcelableArrayList(STATE_EDITS) - ?.map(::decodeEdit) - ?.forEach { edits[it.uid to it.gameDay] = it } - } + edits.restore(state, scope.storageId) noteDay = state?.takeIf { it.containsKey(STATE_NOTE_DAY) } ?.getLong(STATE_NOTE_DAY) ?.let(LocalDate::ofEpochDay) @@ -291,10 +280,7 @@ class WeeklyReportActivity : BaseActivity() { private fun render(next: RenderModel) { val report = next.report - if (editingPeriod != null && editingPeriod != report.periodStart) { - editingPeriod = null - edits.clear() - } + edits.retainFor(report.periodStart) model = next val weeklyTable = tables.getOrPut(report.isGunsmokeWeek) { WeeklyTable(this, report.isGunsmokeWeek, next.zone, next.cutlines, horizontalSync) @@ -341,7 +327,7 @@ class WeeklyReportActivity : BaseActivity() { private fun buildTop(next: RenderModel): View { val report = next.report - val editing = editingPeriod == report.periodStart + val editing = edits.isEditing(report.periodStart) return column { setPadding(dp(16), 0, dp(16), dp(12)) addView(PlatoonProfileSelector.card(this@WeeklyReportActivity, compact = true).apply { @@ -449,7 +435,7 @@ class WeeklyReportActivity : BaseActivity() { private fun renderBottom(next: RenderModel) { bottomSections.removeAllViews() - if (editingPeriod == next.report.periodStart) return + if (edits.isEditing(next.report.periodStart)) return renderMembershipEvents(next) bottomSections.addView(sectionHeader(getString(R.string.notes)), lp()) val notesByDay = next.notes.groupBy(WeeklyNote::gameDay) @@ -586,7 +572,7 @@ class WeeklyReportActivity : BaseActivity() { private fun updateTopBar() { val current = model - val editing = current != null && editingPeriod == current.report.periodStart + val editing = current != null && edits.isEditing(current.report.periodStart) editingBack.isEnabled = editing screen.setNavigationEnabled(!editing) val bar = screen.topBar @@ -598,7 +584,7 @@ class WeeklyReportActivity : BaseActivity() { resources.getQuantityString(R.plurals.edited_cells, edits.size, edits.size), ) .setActions(listOf( - BarAction(R.drawable.ic_check, getString(R.string.save_weekly_edits), edits.isNotEmpty() && !work.isBusy(SAVE)) { + BarAction(R.drawable.ic_check, getString(R.string.save_weekly_edits), !edits.isEmpty() && !work.isBusy(SAVE)) { saveEdits() }, )) @@ -665,19 +651,17 @@ class WeeklyReportActivity : BaseActivity() { val weeklyTable = table ?: return val dayLabel = cell?.let { Labels.dayHeader(this, it.gameDay) } ?: getString(R.string.total) val title = getString(R.string.weekly_cell_title, member.name, dayLabel) - if (editingPeriod == current.report.periodStart) { + if (edits.isEditing(current.report.periodStart)) { if (cell == null) { Feedback.show(this, R.string.total_not_editable) return } // The draft is being committed; edits made now would be cleared with it. if (work.isBusy(SAVE)) return - val key = member.uid to cell.gameDay val nextCell = member.days.getOrNull(member.days.indexOf(cell) + 1) val next = nextCell?.let { { onCellTapped(member, it) } } - WeeklyDialogs.showEdit(this, title, current.report.isGunsmokeWeek, member.uid, cell, edits[key], next) { edit -> - if (editingPeriod != current.report.periodStart) return@showEdit - if (edit.isEmpty) edits.remove(key) else edits[key] = edit + WeeklyDialogs.showEdit(this, title, current.report.isGunsmokeWeek, member.uid, cell, edits[member.uid, cell.gameDay], next) { edit -> + if (!edits.isEditing(current.report.periodStart) || !edits.put(edit)) return@showEdit // Rows are addressed in the list the adapter currently shows, not the tapped model. rowsAdapter.notifyMemberChanged(member.uid) updateTopBar() @@ -705,8 +689,7 @@ class WeeklyReportActivity : BaseActivity() { Dialogs.confirm(this, R.string.manual_edit_warning_title, R.string.manual_edit_warning, R.string.start_editing) { // A resume may have re-rendered while the warning was open; edit what is shown now. val current = model ?: return@confirm - editingPeriod = current.report.periodStart - edits.clear() + edits.start(current.report.periodStart) render(current) } } @@ -726,8 +709,7 @@ class WeeklyReportActivity : BaseActivity() { } private fun finishEditing() { - editingPeriod = null - edits.clear() + edits.finish() model?.let(::render) ?: updateTopBar() } @@ -750,8 +732,7 @@ class WeeklyReportActivity : BaseActivity() { task = { repository.replaceWeeklyOverrides(report.periodStart.toEpochDay(), merged.overrides) }, ) { result -> if (result.isSuccess) { - editingPeriod = null - edits.clear() + edits.finish() Feedback.show(this, R.string.weekly_edits_saved) requestRender() } else { @@ -888,7 +869,7 @@ class WeeklyReportActivity : BaseActivity() { override fun onBindViewHolder(holder: RowHolder, position: Int) { val member = members[position] val current = model ?: return - val dayEdits = edits.values.filter { it.uid == member.uid }.associateBy { it.gameDay } + val dayEdits = edits.forMember(member.uid) val rank = current.scoreRanks[member.uid].takeIf { current.report.isGunsmokeWeek } holder.row.bind(member, rank, dayEdits) { cell -> onCellTapped(member, cell) } } @@ -896,26 +877,6 @@ class WeeklyReportActivity : BaseActivity() { private class RowHolder(val row: WeeklyTable.MemberRow) : RecyclerView.ViewHolder(row.view) - private fun encodeEdit(edit: WeeklyOverrideEditing.CellEdit) = Bundle().apply { - putLong("uid", edit.uid) - putLong("day", edit.gameDay.toEpochDay()) - edit.merit?.let { putString("merit", it) } - edit.score?.let { putString("score", it) } - edit.attempts?.let { putString("attempts", it) } - edit.attended?.let { putString("attended", it.name) } - edit.dailyPatrol?.let { putString("patrol", it.name) } - } - - private fun decodeEdit(bundle: Bundle) = WeeklyOverrideEditing.CellEdit( - uid = bundle.getLong("uid"), - gameDay = LocalDate.ofEpochDay(bundle.getLong("day")), - merit = bundle.getString("merit"), - score = bundle.getString("score"), - attempts = bundle.getString("attempts"), - attended = bundle.getString("attended")?.let(WeeklyOverrideEditing.Choice::valueOf), - dailyPatrol = bundle.getString("patrol")?.let(WeeklyOverrideEditing.Choice::valueOf), - ) - private data class RenderModel( val zone: ZoneId, val report: WeeklyReportBuilder.Report, @@ -942,9 +903,6 @@ class WeeklyReportActivity : BaseActivity() { const val STATE_REFERENCE_DAY = "weekly.reference_day" const val STATE_PENDING_PNG_NAME = "weekly.pending_png_name" const val STATE_PENDING_EXPORT = "weekly.pending_export" - const val STATE_EDITING_PERIOD = "weekly.editing_period" - const val STATE_EDITING_SCOPE = "weekly.editing_scope" - const val STATE_EDITS = "weekly.edits" const val STATE_NOTE_DAY = "weekly.note_day" const val STATE_NOTE_DRAFT = "weekly.note_draft" } diff --git a/app/src/test/java/dev/gf2log/app/WeeklyEditSessionTest.kt b/app/src/test/java/dev/gf2log/app/WeeklyEditSessionTest.kt new file mode 100644 index 0000000..58125eb --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/WeeklyEditSessionTest.kt @@ -0,0 +1,79 @@ +package dev.gf2log.app + +import dev.gf2log.app.management.WeeklyOverrideEditing.CellEdit +import dev.gf2log.app.management.WeeklyOverrideEditing.Choice +import java.time.LocalDate +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 WeeklyEditSessionTest { + private val week = LocalDate.of(2026, 10, 12) + private val nextWeek = week.plusDays(7) + + @Test + fun `edits are ignored outside edit mode`() { + val session = WeeklyEditSession() + + assertFalse(session.put(CellEdit(uid = 1, gameDay = week, merit = "90"))) + assertTrue(session.isEmpty()) + } + + @Test + fun `an empty edit restores the cell instead of being kept`() { + val session = WeeklyEditSession().apply { start(week) } + session.put(CellEdit(uid = 1, gameDay = week, merit = "90")) + session.put(CellEdit(uid = 1, gameDay = week)) + + assertNull(session[1, week]) + assertEquals(0, session.size) + } + + @Test + fun `a later edit of the same cell replaces the earlier one`() { + val session = WeeklyEditSession().apply { start(week) } + session.put(CellEdit(uid = 1, gameDay = week, merit = "50")) + session.put(CellEdit(uid = 1, gameDay = week, merit = "90", attended = Choice.YES)) + + assertEquals(1, session.size) + assertEquals("90", session[1, week]?.merit) + } + + @Test + fun `member edits are keyed by game day`() { + val session = WeeklyEditSession().apply { start(week) } + session.put(CellEdit(uid = 1, gameDay = week, merit = "10")) + session.put(CellEdit(uid = 1, gameDay = week.plusDays(1), merit = "20")) + session.put(CellEdit(uid = 2, gameDay = week, merit = "30")) + + assertEquals(setOf(week, week.plusDays(1)), session.forMember(1).keys) + assertEquals("30", session.forMember(2).getValue(week).merit) + } + + @Test + fun `rendering another week discards the draft`() { + val session = WeeklyEditSession().apply { start(week) } + session.put(CellEdit(uid = 1, gameDay = week, merit = "90")) + + session.retainFor(week) + assertTrue(session.isEditing(week)) + assertEquals(1, session.size) + + session.retainFor(nextWeek) + assertNull(session.period) + assertTrue(session.isEmpty()) + } + + @Test + fun `starting again clears the previous draft`() { + val session = WeeklyEditSession().apply { start(week) } + session.put(CellEdit(uid = 1, gameDay = week, merit = "90")) + + session.start(week) + + assertTrue(session.isEditing(week)) + assertTrue(session.isEmpty()) + } +} From 9318b7b8b9c5e268fc2e8e69e648761bbdb49870 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:07:02 +0900 Subject: [PATCH 102/125] fix: refresh neighbouring weeks that read a capture across the reset A roster captured exactly at Sunday 05:00 closes the previous week, and a current roster or membership event is the carried-in baseline of later weeks, but only the capture's own game week was re-recorded. Weekly history refresh now also re-records those listed neighbours within the per-ingest bound. --- .../PlatoonRepositoryIntegrationTest.kt | 67 +++++++- .../gf2log/app/management/PlatoonDatabase.kt | 4 + .../app/management/PlatoonReadQueries.kt | 8 + .../app/management/PlatoonRepository.kt | 22 ++- .../app/management/WeeklyHistoryWorkPolicy.kt | 49 +++++- .../PlatoonObservationPolicyTest.kt | 14 -- .../management/WeeklyHistoryWorkPolicyTest.kt | 149 ++++++++++++++++++ docs/ARCHITECTURE.md | 6 +- docs/PLATOON_MANAGEMENT.md | 9 ++ 9 files changed, 301 insertions(+), 27 deletions(-) create mode 100644 app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt 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 ba26b0c..8497136 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt @@ -4,6 +4,7 @@ import android.content.ContentValues import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.protocol.model.GuildMember import java.io.File import java.time.Instant @@ -11,6 +12,7 @@ import java.time.LocalDate import java.time.ZoneOffset import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -59,12 +61,69 @@ class PlatoonRepositoryIntegrationTest { assertEquals(oversizedName, revision.report.members.single().name) } - private fun member(uid: Long, name: String) = GuildMember( + @Test + fun rosterCapturedExactlyAtTheSundayResetAlsoRefreshesTheWeekItCloses() { + val repository = utcRepository() + repository.ingest(Instant.parse("2026-07-29T12:00:00Z"), listOf(member(UID, "A", 100u)), "a.csv") + repository.ingest(Instant.parse("2026-08-01T04:56:00Z"), listOf(member(UID, "A", 200u)), "b.csv") + val before = latestStoredRevision(repository, CLOSING_WEEK) + + // 05:00 on Sunday opens the next game week but is the closing capture of Saturday. + repository.ingest(Instant.parse("2026-08-02T05:00:00Z"), listOf(member(UID, "A", 300u)), "c.csv") + + val after = latestStoredRevision(repository, CLOSING_WEEK) + assertNotEquals(before, after) + assertEquals(liveReport(repository, CLOSING_WEEK), after) + } + + @Test + fun rosterCapturedJustBeforeTheResetRefreshesTheFollowingWeekThatCarriesItIn() { + val repository = utcRepository() + repository.ingest( + Instant.parse("2026-07-29T12:00:00Z"), + listOf(member(UID, "A", 100u), member(OTHER_UID, "B", 100u)), + "a.csv", + ) + // The following week already has evidence of its own, so its revision is listed. + repository.addWeeklyNote(OPENING_WEEK.toEpochDay(), OPENING_WEEK.plusDays(1).toEpochDay(), "Note") + val before = latestStoredRevision(repository, OPENING_WEEK) + assertEquals(setOf(UID, OTHER_UID), before?.members?.map { it.uid }?.toSet()) + + // 04:55 belongs to Saturday's game day, yet this roster is the opening baseline and the + // period-end roster of the following week, which no longer contains the withdrawn B. + repository.ingest(Instant.parse("2026-08-02T04:55:00Z"), listOf(member(UID, "A", 300u)), "b.csv") + + val after = latestStoredRevision(repository, OPENING_WEEK) + assertEquals(listOf(UID), after?.members?.map { it.uid }) + assertEquals(liveReport(repository, OPENING_WEEK), after) + } + + private fun utcRepository(): PlatoonRepository { + GameTimeZonePreferences.set(context, ZoneOffset.UTC, SCOPE.storageId) + return PlatoonRepository(context, SCOPE) + } + + private fun latestStoredRevision( + repository: PlatoonRepository, + periodStart: LocalDate, + ): WeeklyReportBuilder.Report? = repository.listWeeklyReportHistory(periodStart) + .firstOrNull() + ?.let { repository.weeklyReportHistory(it.id, periodStart) } + + /** The live revision as history stores it, so codec normalization does not differ. */ + private fun liveReport(repository: PlatoonRepository, periodStart: LocalDate) = + WeeklyReportHistoryCodec.decode( + WeeklyReportHistoryCodec.encode( + repository.readWeeklyTableRevision(periodStart, ZoneOffset.UTC), + ).payload, + ) + + private fun member(uid: Long, name: String, totalMerit: UInt = 0u) = GuildMember( uid = uid.toUInt(), name = name, level = 1u, weeklyMerit = 0u, - totalMerit = 0u, + totalMerit = totalMerit, highScore = 0u, totalScore = 0u, lastLogin = 0u, @@ -75,10 +134,14 @@ class PlatoonRepositoryIntegrationTest { context.deleteDatabase(SCOPE.databaseName) } File(context.filesDir, "platoons/${SCOPE.storageId}").deleteRecursively() + GameTimeZonePreferences.clearScope(context, SCOPE.storageId) } private companion object { val SCOPE = PlatoonStorageScope("1".repeat(32)) const val UID = 4242L + const val OTHER_UID = 4343L + val CLOSING_WEEK: LocalDate = LocalDate.of(2026, 7, 26) + val OPENING_WEEK: LocalDate = LocalDate.of(2026, 8, 2) } } 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 b587d11..3e8e4eb 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -93,6 +93,10 @@ internal class PlatoonDatabase private constructor( fun latestSnapshotIdentity(): Pair? = PlatoonReadQueries.latestSnapshotIdentity(readableDatabase) + @Synchronized + fun firstSnapshotCapturedAfter(instant: Instant): Instant? = + PlatoonReadQueries.firstSnapshotCapturedAfter(readableDatabase, instant) + @Synchronized fun snapshotSourceFiles(): Set = PlatoonReadQueries.snapshotSourceFiles(readableDatabase) 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 a9b5a31..1ccb9b3 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt @@ -21,6 +21,14 @@ internal object PlatoonReadQueries { } } + /** The earliest roster captured strictly after [instant], if any. */ + fun firstSnapshotCapturedAfter(db: SQLiteDatabase, instant: Instant): Instant? = db.rawQuery( + "SELECT MIN(captured_at) FROM snapshots WHERE captured_at > ?", + arrayOf(instant.toEpochMilli().toString()), + ).use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) Instant.ofEpochMilli(cursor.getLong(0)) else null + } + fun snapshotSourceFiles(db: SQLiteDatabase): Set = db.rawQuery( "SELECT source_file FROM snapshots WHERE source_file IS NOT NULL", null, 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 9a40375..dfa5be2 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -470,12 +470,26 @@ internal class PlatoonRepository( private fun recordChangedWeeks(instants: Iterable) = recordChangedWeeks(instants.asSequence()) + /** + * Refreshes the weeks that evidence at [instants] can change, including neighbouring weeks + * that read it across a 05:00 reset (see [WeeklyHistoryWorkPolicy.affectedPeriodStarts]). + * If the neighbour scan fails, the instants' own game weeks are still refreshed. + */ private fun recordChangedWeeks(instants: Sequence) { val zone = gameZone() - WeeklyHistoryWorkPolicy.changedPeriodStarts(instants, zone) - .forEach { periodStart -> - recordLiveWeeklyRevisionSafely(periodStart, zone) - } + val changed = instants.toList() + val weeks = try { + WeeklyHistoryWorkPolicy.affectedPeriodStarts( + instants = changed.asSequence(), + zoneId = zone, + listedPeriodStarts = weeklyPeriodStarts(zone), + nextSnapshotAfter = { instant -> access { it.firstSnapshotCapturedAfter(instant) } }, + ) + } catch (error: Exception) { + Log.e(TAG, "Unable to scope neighbouring weekly history; refreshing own weeks", error) + WeeklyHistoryWorkPolicy.affectedPeriodStarts(changed.asSequence(), zone, emptyList()) { null } + } + weeks.forEach { periodStart -> recordLiveWeeklyRevisionSafely(periodStart, zone) } } /** Refreshes every derived weekly revision after a primary-data mutation. */ diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt index 5223ab9..e779cce 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicy.kt @@ -8,12 +8,49 @@ import java.time.ZoneId internal object WeeklyHistoryWorkPolicy { const val MAX_CHANGED_WEEKS_PER_INGEST = 32 - fun changedPeriodStarts( + /** + * Every week whose live revision can change when evidence at [instants] is stored: the game + * week of each instant, then the listed neighbours that also read it. A roster at exactly a + * Sunday 05:00 reset is the closing Saturday capture of the previous week; an instant before + * 05:00 on a calendar Sunday falls in the next week's calendar-day Join / Withdraw section; + * and every later week up to the next stored roster carries the instant in as its baseline + * roster or as a roster-changing membership event. Neighbours are limited to + * [listedPeriodStarts], so no revision is recorded for a week the history does not list, and + * the whole result stays within [MAX_CHANGED_WEEKS_PER_INGEST], game weeks first. + */ + fun affectedPeriodStarts( instants: Sequence, zoneId: ZoneId, - ): List = instants - .map { PlatoonPeriods.weekStart(PlatoonPeriods.gameDay(it, zoneId)) } - .distinct() - .take(MAX_CHANGED_WEEKS_PER_INGEST) - .toList() + listedPeriodStarts: Collection, + nextSnapshotAfter: (Instant) -> Instant?, + ): List { + val latestByWeek = linkedMapOf() + val neighbours = sortedSetOf() + instants.forEach { instant -> + val week = PlatoonPeriods.weekStart(PlatoonPeriods.gameDay(instant, zoneId)) + val latest = latestByWeek[week] + if (latest == null && latestByWeek.size == MAX_CHANGED_WEEKS_PER_INGEST) return@forEach + if (latest == null || instant.isAfter(latest)) latestByWeek[week] = instant + if (instant == PlatoonPeriods.periodStartInstant(week, zoneId)) { + neighbours += week.minusDays(DAYS_PER_WEEK) + } + neighbours += PlatoonPeriods.weekStart(instant.atZone(zoneId).toLocalDate()) + } + val listed = listedPeriodStarts.distinct().sorted() + latestByWeek.forEach { (week, latest) -> + val nextSnapshot = nextSnapshotAfter(latest) + listed.asSequence() + .dropWhile { !it.isAfter(week) } + .takeWhile { later -> + nextSnapshot == null || + !nextSnapshot.isBefore(PlatoonPeriods.periodStartInstant(later, zoneId)) + } + .forEach(neighbours::add) + } + return (latestByWeek.keys + neighbours.filter { it in listed }) + .distinct() + .take(MAX_CHANGED_WEEKS_PER_INGEST) + } + + private const val DAYS_PER_WEEK = 7L } diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt index ec4a0fc..798fc57 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonObservationPolicyTest.kt @@ -6,7 +6,6 @@ import dev.gf2log.protocol.model.PlatoonUpdateEntry import dev.gf2log.protocol.model.PlatoonUpdateMember import dev.gf2log.protocol.model.PlatoonUpdatesData import java.time.Instant -import java.time.ZoneOffset import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -164,19 +163,6 @@ class PlatoonObservationPolicyTest { ) } - @Test - fun `weekly history work is capped after distinct week mapping`() { - val zone = ZoneOffset.UTC - val instants = generateSequence(Instant.parse("2020-01-06T05:00:00Z")) { - it.plusSeconds(7 * 24 * 60 * 60L) - }.take(WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST + 50) - - val periods = WeeklyHistoryWorkPolicy.changedPeriodStarts(instants, zone) - - assertEquals(WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST, periods.size) - assertEquals(periods.size, periods.distinct().size) - } - private fun activity(name: String) = PlatoonActivityObservation( occurredAt = Instant.parse("2026-07-31T00:00:00Z"), actionId = 1, diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt new file mode 100644 index 0000000..3b14e3e --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyHistoryWorkPolicyTest.kt @@ -0,0 +1,149 @@ +package dev.gf2log.app.management + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Test + +class WeeklyHistoryWorkPolicyTest { + private val utc = ZoneOffset.UTC + private val closingWeek = LocalDate.of(2026, 7, 26) + private val openingWeek = LocalDate.of(2026, 8, 2) + private val laterWeek = LocalDate.of(2026, 8, 9) + private val listed = listOf(closingWeek, openingWeek, laterWeek) + + @Test + fun `weekly history work is capped after distinct week mapping`() { + val instants = generateSequence(Instant.parse("2020-01-06T05:00:00Z")) { + it.plusSeconds(7 * 24 * 60 * 60L) + }.take(WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST + 50) + + val periods = WeeklyHistoryWorkPolicy.affectedPeriodStarts(instants, utc, emptyList()) { null } + + assertEquals(WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST, periods.size) + assertEquals(periods.size, periods.distinct().size) + } + + @Test + fun `neighbouring weeks never push the result past the per-ingest bound`() { + val weeks = (0L until WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST + 10L) + .map { openingWeek.plusWeeks(it) } + + val periods = WeeklyHistoryWorkPolicy.affectedPeriodStarts( + sequenceOf(Instant.parse("2026-08-03T12:00:00Z")), + utc, + weeks, + ) { null } + + assertEquals(WeeklyHistoryWorkPolicy.MAX_CHANGED_WEEKS_PER_INGEST, periods.size) + assertEquals(openingWeek, periods.first()) + } + + @Test + fun `a mid-week instant before a later roster affects only its own week`() { + val periods = affected( + Instant.parse("2026-07-29T12:00:00Z"), + nextSnapshot = Instant.parse("2026-07-30T12:00:00Z"), + ) + + assertEquals(listOf(closingWeek), periods) + } + + @Test + fun `a roster at exactly the Sunday reset also affects the week it closes`() { + val periods = affected( + Instant.parse("2026-08-02T05:00:00Z"), + nextSnapshot = Instant.parse("2026-08-03T12:00:00Z"), + ) + + assertEquals(listOf(openingWeek, closingWeek), periods) + } + + @Test + fun `a roster just before the reset is the next week's baseline`() { + val periods = affected( + Instant.parse("2026-08-02T04:55:00Z"), + nextSnapshot = Instant.parse("2026-08-02T12:00:00Z"), + ) + + assertEquals(listOf(closingWeek, openingWeek), periods) + } + + @Test + fun `the newest roster carries into every later listed week`() { + val periods = affected(Instant.parse("2026-07-31T12:00:00Z"), nextSnapshot = null) + + assertEquals(listOf(closingWeek, openingWeek, laterWeek), periods) + } + + @Test + fun `carry-in stops at the first week opened after the next roster`() { + // A next roster at the opening reset itself is not yet before that week, so it still + // takes the earlier instant as its carried-in state. + assertEquals( + listOf(closingWeek, openingWeek), + affected( + Instant.parse("2026-07-31T12:00:00Z"), + nextSnapshot = Instant.parse("2026-08-02T05:00:00Z"), + ), + ) + assertEquals( + listOf(closingWeek), + affected( + Instant.parse("2026-07-31T12:00:00Z"), + nextSnapshot = Instant.parse("2026-08-02T04:59:59Z"), + ), + ) + } + + @Test + fun `an early calendar Sunday instant affects the next week's Join and Withdraw section`() { + val seoul = ZoneId.of("Asia/Seoul") + // 03:00 on Sunday 2026-08-02 in Seoul is still Saturday's game day. + val instant = Instant.parse("2026-08-01T18:00:00Z") + + val periods = WeeklyHistoryWorkPolicy.affectedPeriodStarts( + sequenceOf(instant), + seoul, + listed, + ) { Instant.parse("2026-08-01T19:00:00Z") } + + assertEquals(listOf(closingWeek, openingWeek), periods) + } + + @Test + fun `neighbouring weeks are refreshed only when the history lists them`() { + val periods = WeeklyHistoryWorkPolicy.affectedPeriodStarts( + sequenceOf(Instant.parse("2026-08-02T05:00:00Z")), + utc, + listOf(openingWeek), + ) { null } + + assertEquals(listOf(openingWeek), periods) + } + + @Test + fun `the next roster is looked up once after the latest instant of each week`() { + val lookups = mutableListOf() + + WeeklyHistoryWorkPolicy.affectedPeriodStarts( + sequenceOf( + Instant.parse("2026-07-29T12:00:00Z"), + Instant.parse("2026-07-31T12:00:00Z"), + Instant.parse("2026-07-30T12:00:00Z"), + ), + utc, + listed, + ) { instant -> + lookups += instant + null + } + + assertEquals(listOf(Instant.parse("2026-07-31T12:00:00Z")), lookups) + } + + private fun affected(instant: Instant, nextSnapshot: Instant?) = + WeeklyHistoryWorkPolicy.affectedPeriodStarts(sequenceOf(instant), utc, listed) { nextSnapshot } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 43dc6bd..1f8f66d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -212,7 +212,11 @@ payload with 128-character names, and Updates ingestion accepts at most 250 distinct supported observations. Packet-triggered weekly-history work derives only from those accepted observations and is independently capped at 32 distinct periods per ingest, so rejected timestamps cannot amplify report or -SQLite work. SQLite retains the newest 10,000 activity +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 cursor rotates those bounded batches through the complete retained backlog. Schema v11 owns the rotation cursor and a global `(captured_at, id)` retention diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index f7078e7..0ad6011 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -194,6 +194,15 @@ 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. +- 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 + too. A current roster, or a membership event before the next roster, is the + carried-in baseline and period-end roster of every later listed week until + the next stored roster, so those weeks are re-recorded as well; this covers a + roster captured just before the reset. An instant before 05:00 on a calendar + Sunday also refreshes the following week, whose Join / Withdraw section uses + calendar days. The 32-period per-ingest bound still applies, own weeks first. - Weekly history is a derived audit trail, not the primary store. If refreshing one revision fails after a successful data mutation, the affected restored selection is cleared so the live database projection remains authoritative. From db36ca6d42aff67ce2e15521687e0cbc2356bc2c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:07:19 +0900 Subject: [PATCH 103/125] refactor: extract weekly CSV and PNG exports from the weekly screen --- .../java/dev/gf2log/app/OptionsActivity.kt | 1 - .../java/dev/gf2log/app/WeeklyExportFlow.kt | 160 ++++++++++++++++++ .../dev/gf2log/app/WeeklyReportActivity.kt | 135 ++------------- 3 files changed, 175 insertions(+), 121 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 3abf198..a4e5819 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -7,7 +7,6 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.text.InputType -import android.view.View import android.widget.LinearLayout import androidx.activity.result.contract.ActivityResultContracts import dev.gf2log.app.capture.CaptureDiagnosticsStore diff --git a/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt b/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt new file mode 100644 index 0000000..7a0468b --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyExportFlow.kt @@ -0,0 +1,160 @@ +package dev.gf2log.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.management.WeeklyReportCsv +import dev.gf2log.app.management.WeeklyShareProjection +import dev.gf2log.app.settings.GameTimeZonePreferences +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import java.io.File +import java.time.LocalDate + +/** + * Weekly-table exports: CSV documents for one or every week, clipboard CSV, and the privacy-filtered + * PNG that is shared or saved. The pending request survives recreation and process death, so a + * document picker that returns to a new instance still writes what the user asked for. + */ +internal class WeeklyExportFlow( + private val activity: ComponentActivity, + private val work: BackgroundWork, + private val repository: () -> PlatoonRepository, + private val storageId: () -> String, +) { + /** What a PNG is rendered from: the table as currently ordered and annotated on screen. */ + class Source( + val report: WeeklyReportBuilder.Report, + val displayedMembers: List, + val memberNotesByUid: Map, + ) + + private var pendingCsv: String? = null + private var pendingPng: File? = null + + private val csvDocument = activity.registerForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { uri -> + val request = pendingCsv + pendingCsv = null + if (uri == null || request == null) return@registerForActivityResult + work.run( + task = { + val zone = GameTimeZonePreferences.get(activity, storageId()) + val csv = if (request == EXPORT_ALL) { + WeeklyReportCsv.formatAll(repository().listAllWeeklyReports(zone)) + } else { + WeeklyReportCsv.format( + repository().buildWeeklyTableRevision(LocalDate.ofEpochDay(request.toLong()), zone).report, + ) + } + DocumentAccess.openOutput(activity.contentResolver, uri).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) + } + } + + private val pngDocument = activity.registerForActivityResult(ActivityResultContracts.CreateDocument("image/png")) { uri -> + val source = pendingPng + pendingPng = null + if (uri == null || source == null) { + source?.delete() + return@registerForActivityResult + } + work.run( + task = { + DocumentAccess.openOutput(activity.contentResolver, uri).use { output -> + source.inputStream().use { it.copyTo(output) } + } + }, + ) { result -> + source.delete() + Feedback.show(activity, if (result.isSuccess) R.string.weekly_png_saved else R.string.weekly_png_failed) + } + } + + fun saveState(outState: Bundle) { + WeeklyPngExport.nameForState(activity.cacheDir, pendingPng)?.let { outState.putString(STATE_PENDING_PNG_NAME, it) } + pendingCsv?.let { outState.putString(STATE_PENDING_CSV, it) } + } + + fun restoreState(state: Bundle?) { + pendingPng = WeeklyPngExport.restore(activity.cacheDir, state?.getString(STATE_PENDING_PNG_NAME)) + pendingCsv = state?.getString(STATE_PENDING_CSV) + } + + fun exportWeek(periodStart: LocalDate) { + pendingCsv = periodStart.toEpochDay().toString() + csvDocument.launch("GF2logger-week-${Labels.format(Labels.FILE_DATE, periodStart)}.csv") + } + + fun exportAll() { + work.run( + task = { repository().listAllWeeklyReports(GameTimeZonePreferences.get(activity, storageId())).isNotEmpty() }, + ) { result -> + if (result.getOrDefault(false)) { + pendingCsv = EXPORT_ALL + csvDocument.launch("GF2logger-all-weekly-tables.csv") + } else { + Feedback.show(activity, R.string.no_weekly_tables_to_export) + } + } + } + + fun copyWeek(report: WeeklyReportBuilder.Report) { + Feedback.copy( + activity, + activity.getString(R.string.weekly_csv_clipboard_label), + WeeklyReportCsv.format(report), + R.string.weekly_csv_copied, + ) + } + + /** Asks for the privacy options, then shares or saves a PNG of what [source] returns then. */ + fun share(source: () -> Source?) { + if (source() == null || work.isBusy(PNG)) return + WeeklyDialogs.showShare(activity) { privacy, shareAfter -> + // A second share sheet opened by a double tap must not start another render. + val current = source() ?: return@showShare + if (work.isBusy(PNG)) return@showShare + val periodStart = current.report.periodStart + Feedback.show(activity, R.string.weekly_png_rendering) + work.run( + key = PNG, + task = { + WeeklyPngExport.render( + activity, + WeeklyShareProjection.build( + current.report, + current.displayedMembers, + current.memberNotesByUid, + privacy, + WeeklyPngExport.labels(activity), + ), + periodStart, + ) + }, + ) { result -> + val file = result.getOrNull() + when { + file == null -> Feedback.show(activity, R.string.weekly_png_failed, long = true) + shareAfter -> WeeklyPngExport.share(activity, file) + else -> { + pendingPng = file + pngDocument.launch(WeeklyPngExport.exportName(periodStart)) + } + } + } + } + } + + private companion object { + const val PNG = "png" + const val EXPORT_ALL = "all" + const val STATE_PENDING_PNG_NAME = "weekly.pending_png_name" + const val STATE_PENDING_CSV = "weekly.pending_export" + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 465c1c1..15b0781 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -12,7 +12,6 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import androidx.activity.OnBackPressedCallback -import androidx.activity.result.contract.ActivityResultContracts import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -28,9 +27,7 @@ import dev.gf2log.app.management.WeeklyNote import dev.gf2log.app.management.WeeklyNoteLimitException import dev.gf2log.app.management.WeeklyOverrideEditing import dev.gf2log.app.management.WeeklyReportBuilder -import dev.gf2log.app.management.WeeklyReportCsv import dev.gf2log.app.management.WeeklyReportStateHolder -import dev.gf2log.app.management.WeeklyShareProjection import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.MemberOrderPreferences import dev.gf2log.app.settings.WeeklyCutlinePreferences @@ -39,7 +36,6 @@ 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.DocumentAccess import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.MATCH @@ -63,7 +59,6 @@ import dev.gf2log.app.ui.sectionHeader import dev.gf2log.app.ui.selectableRect import dev.gf2log.app.ui.text import dev.gf2log.app.ui.textField -import java.io.File import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -85,6 +80,7 @@ class WeeklyReportActivity : BaseActivity() { private lateinit var noteInput: EditText private lateinit var noteDayButton: TextView private val work = BackgroundWork(this, "GF2Weekly") + private val exports = WeeklyExportFlow(this, work, { repository }, { scope.storageId }) private val topAdapter = SingleViewAdapter() private val headerAdapter = SingleViewAdapter() @@ -100,49 +96,6 @@ class WeeklyReportActivity : BaseActivity() { private val horizontalSync = HorizontalSync() private val edits = WeeklyEditSession() private var noteDay: LocalDate? = null - private var pendingExport: String? = null - private var pendingPng: File? = null - - private val exportCsv = registerForActivityResult(ActivityResultContracts.CreateDocument("text/csv")) { uri -> - val request = pendingExport - pendingExport = null - if (uri == null || request == null) return@registerForActivityResult - work.run( - task = { - val zone = GameTimeZonePreferences.get(this, scope.storageId) - val csv = if (request == EXPORT_ALL) { - WeeklyReportCsv.formatAll(repository.listAllWeeklyReports(zone)) - } else { - WeeklyReportCsv.format( - repository.buildWeeklyTableRevision(LocalDate.ofEpochDay(request.toLong()), zone).report, - ) - } - DocumentAccess.openOutput(contentResolver, uri).writer(Charsets.UTF_8).use { it.write(csv) } - }, - ) { result -> - Feedback.show(this, if (result.isSuccess) R.string.weekly_csv_exported else R.string.weekly_csv_export_failed) - } - } - - private val exportPng = registerForActivityResult(ActivityResultContracts.CreateDocument("image/png")) { uri -> - val source = pendingPng - pendingPng = null - if (uri == null || source == null) { - source?.delete() - return@registerForActivityResult - } - work.run( - task = { - DocumentAccess.openOutput(contentResolver, uri).use { output -> - source.inputStream().use { it.copyTo(output) } - } - }, - ) { result -> - source.delete() - Feedback.show(this, if (result.isSuccess) R.string.weekly_png_saved else R.string.weekly_png_failed) - } - } - private val editingBack = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() = cancelEditing() } @@ -199,8 +152,7 @@ class WeeklyReportActivity : BaseActivity() { if (::reportState.isInitialized) { outState.putLong(STATE_REFERENCE_DAY, reportState.referenceDay.toEpochDay()) } - WeeklyPngExport.nameForState(cacheDir, pendingPng)?.let { outState.putString(STATE_PENDING_PNG_NAME, it) } - pendingExport?.let { outState.putString(STATE_PENDING_EXPORT, it) } + exports.saveState(outState) if (::scope.isInitialized) edits.save(outState, scope.storageId) noteDay?.let { outState.putLong(STATE_NOTE_DAY, it.toEpochDay()) } // The composer lives inside the RecyclerView, which does not save its children's state. @@ -209,8 +161,7 @@ class WeeklyReportActivity : BaseActivity() { } private fun restoreState(state: Bundle?) { - pendingPng = WeeklyPngExport.restore(cacheDir, state?.getString(STATE_PENDING_PNG_NAME)) - pendingExport = state?.getString(STATE_PENDING_EXPORT) + exports.restoreState(state) reportState = WeeklyReportStateHolder( state?.takeIf { it.containsKey(STATE_REFERENCE_DAY) } ?.getLong(STATE_REFERENCE_DAY) @@ -596,16 +547,24 @@ class WeeklyReportActivity : BaseActivity() { .setTitle(getString(R.string.weekly_table)) .setActions( listOf( - BarAction(R.drawable.ic_share, getString(R.string.share_weekly_table), hasMembers) { share() }, + BarAction(R.drawable.ic_share, getString(R.string.share_weekly_table), hasMembers) { + exports.share { + model?.let { WeeklyExportFlow.Source(it.report, it.displayedMembers, it.memberNotesByUid) } + } + }, BarAction(R.drawable.ic_edit, getString(R.string.edit_weekly_table), hasMembers) { confirmEditing() }, ), overflow = listOf( MenuEntry(getString(R.string.weekly_table_history_with_count, historyCount), historyCount > 0) { current?.let { openHistory(it.report.periodStart) } }, - MenuEntry(getString(R.string.export_weekly_csv), hasMembers) { exportWeek() }, - MenuEntry(getString(R.string.copy_weekly_csv), hasMembers) { copyWeek() }, - MenuEntry(getString(R.string.export_all_weekly_tables)) { exportAll() }, + MenuEntry(getString(R.string.export_weekly_csv), hasMembers) { + current?.let { exports.exportWeek(it.report.periodStart) } + }, + MenuEntry(getString(R.string.copy_weekly_csv), hasMembers) { + current?.let { exports.copyWeek(it.report) } + }, + MenuEntry(getString(R.string.export_all_weekly_tables)) { exports.exportAll() }, MenuEntry(getString(R.string.edit_member_order)) { startActivity(Intent(this, MemberOrderActivity::class.java)) }, @@ -745,66 +704,6 @@ class WeeklyReportActivity : BaseActivity() { } } - private fun exportWeek() { - val current = model ?: return - pendingExport = current.report.periodStart.toEpochDay().toString() - exportCsv.launch("GF2logger-week-${Labels.format(Labels.FILE_DATE, current.report.periodStart)}.csv") - } - - private fun exportAll() { - work.run( - task = { repository.listAllWeeklyReports(GameTimeZonePreferences.get(this, scope.storageId)).isNotEmpty() }, - ) { result -> - if (result.getOrDefault(false)) { - pendingExport = EXPORT_ALL - exportCsv.launch("GF2logger-all-weekly-tables.csv") - } else { - Feedback.show(this, R.string.no_weekly_tables_to_export) - } - } - } - - private fun copyWeek() { - val report = model?.report ?: return - Feedback.copy(this, getString(R.string.weekly_csv_clipboard_label), WeeklyReportCsv.format(report), R.string.weekly_csv_copied) - } - - private fun share() { - if (model == null || work.isBusy(PNG)) return - WeeklyDialogs.showShare(this) { privacy, shareAfter -> - // A second share sheet opened by a double tap must not start another render. - val current = model ?: return@showShare - if (work.isBusy(PNG)) return@showShare - Feedback.show(this, R.string.weekly_png_rendering) - work.run( - key = PNG, - task = { - WeeklyPngExport.render( - this, - WeeklyShareProjection.build( - current.report, - current.displayedMembers, - current.memberNotesByUid, - privacy, - WeeklyPngExport.labels(this), - ), - current.report.periodStart, - ) - }, - ) { result -> - val file = result.getOrNull() - when { - file == null -> Feedback.show(this, R.string.weekly_png_failed, long = true) - shareAfter -> WeeklyPngExport.share(this, file) - else -> { - pendingPng = file - exportPng.launch(WeeklyPngExport.exportName(current.report.periodStart)) - } - } - } - } - } - private fun addNote(report: WeeklyReportBuilder.Report) { val text = noteInput.text.toString().trim() val day = noteDay ?: return @@ -897,12 +796,8 @@ class WeeklyReportActivity : BaseActivity() { const val STANDARD_ROW = 0 const val GUNSMOKE_ROW = 1 const val SAVE = "save" - const val PNG = "png" - const val EXPORT_ALL = "all" const val MAX_NOTE_LENGTH = 1_000 const val STATE_REFERENCE_DAY = "weekly.reference_day" - const val STATE_PENDING_PNG_NAME = "weekly.pending_png_name" - const val STATE_PENDING_EXPORT = "weekly.pending_export" const val STATE_NOTE_DAY = "weekly.note_day" const val STATE_NOTE_DRAFT = "weekly.note_draft" } From cdc722371a33100f126bdc188a86b82dd5ca7602 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:09:07 +0900 Subject: [PATCH 104/125] refactor: extract the weekly notes and membership footer --- .../dev/gf2log/app/WeeklyJournalSection.kt | 274 ++++++++++++++++++ .../dev/gf2log/app/WeeklyReportActivity.kt | 215 +------------- 2 files changed, 285 insertions(+), 204 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt diff --git a/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt b/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt new file mode 100644 index 0000000..a4c6a88 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt @@ -0,0 +1,274 @@ +package dev.gf2log.app + +import android.app.Activity +import android.content.res.ColorStateList +import android.os.Bundle +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.TextView +import dev.gf2log.app.management.MemberEvent +import dev.gf2log.app.management.MemberEventType +import dev.gf2log.app.management.MembershipEventPresentation +import dev.gf2log.app.management.PlatoonPeriods +import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.WeeklyNote +import dev.gf2log.app.management.WeeklyNoteLimitException +import dev.gf2log.app.management.WeeklyReportBuilder +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.iconButton +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * The weekly screen's footer: the week's joins and withdrawals, its notes, and the note composer. + * The composer views live for the whole screen so a typed draft survives every re-render, and + * the draft and chosen day are saved with the screen because the RecyclerView does not save them. + */ +internal class WeeklyJournalSection( + private val activity: Activity, + private val work: BackgroundWork, + private val saveKey: String, + private val repository: () -> PlatoonRepository, + private val onChanged: () -> Unit, +) { + /** What the footer shows for one rendered week. */ + class Content( + val report: WeeklyReportBuilder.Report, + val notes: List, + val events: List, + val namesByUid: Map, + val zone: ZoneId, + ) + + private val sections = activity.column() + val view: View = activity.column { + setPadding(context.dp(16), 0, context.dp(16), context.dp(24)) + addView(sections, context.lp()) + } + + private val noteInput: EditText = activity.textField( + activity.getString(R.string.note_hint), + id = R.id.weekly_note_input, + singleLine = false, + maxLength = MAX_NOTE_LENGTH, + ) + private val noteDayButton: TextView = activity.text(null, TextRole.BODY) { + gravity = Gravity.CENTER_VERTICAL + minHeight = context.dp(48) + setPadding(context.dp(12), 0, context.dp(8), 0) + background = context.selectableRect() + setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_today, 0, R.drawable.ic_expand_more, 0) + compoundDrawablePadding = context.dp(8) + compoundDrawableTintList = ColorStateList.valueOf(context.getColor(R.color.text_secondary)) + } + private var noteDay: LocalDate? = null + + fun saveState(outState: Bundle) { + noteDay?.let { outState.putLong(STATE_NOTE_DAY, it.toEpochDay()) } + outState.putString(STATE_NOTE_DRAFT, noteInput.text.toString()) + } + + fun restoreState(state: Bundle?) { + noteDay = state?.takeIf { it.containsKey(STATE_NOTE_DAY) }?.getLong(STATE_NOTE_DAY)?.let(LocalDate::ofEpochDay) + state?.getString(STATE_NOTE_DRAFT)?.let(noteInput::setText) + } + + fun clear() = sections.removeAllViews() + + fun render(content: Content) { + sections.removeAllViews() + renderMembershipEvents(content) + sections.addView(activity.sectionHeader(activity.getString(R.string.notes)), activity.lp()) + val notesByDay = content.notes.groupBy(WeeklyNote::gameDay) + if (notesByDay.isEmpty()) { + sections.addView(placeholder(R.string.no_notes), activity.lp()) + } else { + sections.addView(dayPanel(content.report.days.filter { it in notesByDay }) { day -> + notesByDay[day].orEmpty().map(::noteRow) + }, activity.lp()) + } + sections.addView(composer(content.report, content.zone), activity.lp(top = 8)) + } + + private fun renderMembershipEvents(content: Content) { + val events = MembershipEventPresentation.deduplicate(content.events) + sections.addView(activity.sectionHeader(activity.getString(R.string.join_withdraw)), activity.lp()) + if (events.isEmpty()) { + sections.addView(placeholder(R.string.no_membership_events), activity.lp()) + return + } + val byDay = events.groupBy { MembershipEventPresentation.calendarDate(it, content.zone) } + val ordered: List = content.report.days.filter { it in byDay } + + if (null in byDay) listOf(null) else emptyList() + sections.addView(dayPanel(ordered) { day -> + byDay[day].orEmpty().map { eventRow(it, content) } + }, activity.lp()) + } + + private fun placeholder(message: Int): View = activity.text(activity.getString(message), TextRole.SECONDARY) { + setPadding(context.dp(4), 0, context.dp(4), context.dp(8)) + } + + /** One panel grouping rows under game-day headings; a null day collects undated rows. */ + private fun dayPanel(days: List, rows: (LocalDate?) -> List): View = activity.panel(padding = 8) { + days.forEachIndexed { index, day -> + if (index > 0) addView(context.divider(), context.lp(top = 4, bottom = 4)) + val heading = day?.let { Labels.dayHeader(context, it) } ?: context.getString(R.string.unknown_date) + addView(context.text(heading, TextRole.LABEL) { + setPadding(context.dp(8), context.dp(4), context.dp(8), 0) + }, context.lp()) + rows(day).forEach { addView(it, context.lp()) } + } + } + + private fun eventRow(event: MemberEvent, content: Content): View = with(activity) { + val joined = event.type == MemberEventType.JOINED || event.type == MemberEventType.REJOINED + val label = getString(if (joined) R.string.join_label else R.string.withdraw_label) + val name = content.namesByUid[event.uid].orEmpty().ifBlank { event.note } + val identity = name.takeIf(String::isNotBlank)?.let { getString(R.string.member_name_uid, it, event.uid) } + ?: Labels.uid(this, event.uid) + val time = MembershipEventPresentation.timePrefix(event, content.zone) + row { + minimumHeight = dp(40) + setPadding(dp(8), 0, dp(8), 0) + isFocusable = true + contentDescription = listOfNotNull(label, time, identity).joinToString(". ") + addView(icon( + if (joined) R.drawable.ic_arrow_down else R.drawable.ic_arrow_up, + if (joined) R.color.success_mark else R.color.failure_mark, + sizeDp = 18, + ), lp(18, 18, end = 8)) + addView(text(label, TextRole.LABEL) { + setTextColor(getColor(if (joined) R.color.success_text else R.color.destructive_action)) + minWidth = dp(44) + }, lp(WRAP, WRAP, end = 8)) + addView(text(listOfNotNull(time, identity).joinToString(" "), TextRole.BODY) { + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + }, lp(width = 0, weight = 1f)) + } + } + + private fun noteRow(note: WeeklyNote): View = with(activity) { + row { + minimumHeight = dp(48) + setPadding(dp(8), 0, 0, 0) + addView(text(note.text, TextRole.BODY) { setTextIsSelectable(true) }, lp(width = 0, weight = 1f)) + if (!note.isAutomatic) { + addView( + iconButton(R.drawable.ic_delete, getString(R.string.delete_note_description, note.text.take(40)), R.color.text_secondary) { + confirmDeletion(note) + }, + lp(48, 48), + ) + } + } + } + + private fun composer(report: WeeklyReportBuilder.Report, zone: ZoneId): View { + val today = PlatoonPeriods.gameDay(Instant.now(), zone) + if (noteDay !in report.days) noteDay = today.takeIf { it in report.days } ?: report.days.first() + updateNoteDay() + noteDayButton.setOnClickListener { + Dialogs.choose( + activity, + activity.getString(R.string.note_day), + report.days.map { Labels.dayHeader(activity, it) }, + report.days.indexOf(noteDay), + ) { + noteDay = report.days[it] + updateNoteDay() + } + } + (noteInput.parent as? ViewGroup)?.removeView(noteInput) + (noteDayButton.parent as? ViewGroup)?.removeView(noteDayButton) + return activity.panel(padding = 12) { + addView(context.text(context.getString(R.string.add_note), TextRole.SUBTITLE), context.lp()) + addView(noteDayButton, context.lp(top = 8)) + addView(noteInput, context.lp(top = 8)) + addView( + context.button(context.getString(R.string.add_note), Controls.Role.PRIMARY) { addNote(report) }, + context.lp(top = 8), + ) + } + } + + private fun updateNoteDay() { + val day = noteDay ?: return + noteDayButton.text = Labels.dayHeader(activity, day) + noteDayButton.contentDescription = Labels.labeled(activity, activity.getString(R.string.note_day), noteDayButton.text) + } + + private fun addNote(report: WeeklyReportBuilder.Report) { + val text = noteInput.text.toString().trim() + val day = noteDay ?: return + if (work.isBusy(saveKey)) return + if (text.isEmpty()) { + noteInput.error = activity.getString(R.string.note_required) + return + } + work.run( + key = saveKey, + task = { repository().addWeeklyNote(report.periodStart.toEpochDay(), day.toEpochDay(), text) }, + ) { result -> + result.fold( + onSuccess = { + noteInput.text = null + Feedback.show(activity, R.string.note_added) + onChanged() + }, + onFailure = { error -> + Feedback.show( + activity, + if (error is WeeklyNoteLimitException) R.string.weekly_note_limit_reached else R.string.save_failed, + ) + }, + ) + } + } + + private fun confirmDeletion(note: WeeklyNote) { + Dialogs.confirm( + activity, + activity.getString(R.string.delete_note), + note.text, + activity.getString(R.string.delete), + destructive = true, + ) { + work.run(task = { repository().deleteWeeklyNote(note.id) }) { result -> + if (result.getOrDefault(false)) { + Feedback.show(activity, R.string.note_deleted) + onChanged() + } + } + } + } + + private companion object { + const val MAX_NOTE_LENGTH = 1_000 + const val STATE_NOTE_DAY = "weekly.note_day" + const val STATE_NOTE_DRAFT = "weekly.note_draft" + } +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 15b0781..36ff753 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -3,28 +3,21 @@ package dev.gf2log.app import android.app.DatePickerDialog import android.content.Intent import android.os.Bundle -import android.text.TextUtils import android.view.Gravity import android.view.View import android.view.ViewGroup -import android.widget.EditText import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import dev.gf2log.app.management.MemberEvent -import dev.gf2log.app.management.MemberEventType -import dev.gf2log.app.management.MembershipEventPresentation import dev.gf2log.app.management.PlatoonPeriods import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.management.WeeklyEvidenceAnalyzer import dev.gf2log.app.management.WeeklyMemberNameProjection import dev.gf2log.app.management.WeeklyNote -import dev.gf2log.app.management.WeeklyNoteLimitException import dev.gf2log.app.management.WeeklyOverrideEditing import dev.gf2log.app.management.WeeklyReportBuilder import dev.gf2log.app.management.WeeklyReportStateHolder @@ -47,7 +40,6 @@ import dev.gf2log.app.ui.TextRole import dev.gf2log.app.ui.WRAP import dev.gf2log.app.ui.button import dev.gf2log.app.ui.column -import dev.gf2log.app.ui.divider import dev.gf2log.app.ui.dp import dev.gf2log.app.ui.icon import dev.gf2log.app.ui.iconButton @@ -55,10 +47,8 @@ import dev.gf2log.app.ui.lp import dev.gf2log.app.ui.navigationRow import dev.gf2log.app.ui.panel import dev.gf2log.app.ui.row -import dev.gf2log.app.ui.sectionHeader import dev.gf2log.app.ui.selectableRect import dev.gf2log.app.ui.text -import dev.gf2log.app.ui.textField import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -76,9 +66,7 @@ class WeeklyReportActivity : BaseActivity() { private lateinit var screen: Screen private lateinit var list: RecyclerView private lateinit var stickyHeader: FrameLayout - private lateinit var bottomSections: LinearLayout - private lateinit var noteInput: EditText - private lateinit var noteDayButton: TextView + private lateinit var journal: WeeklyJournalSection private val work = BackgroundWork(this, "GF2Weekly") private val exports = WeeklyExportFlow(this, work, { repository }, { scope.storageId }) @@ -95,7 +83,6 @@ class WeeklyReportActivity : BaseActivity() { private val tables = HashMap() private val horizontalSync = HorizontalSync() private val edits = WeeklyEditSession() - private var noteDay: LocalDate? = null private val editingBack = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() = cancelEditing() } @@ -109,16 +96,11 @@ class WeeklyReportActivity : BaseActivity() { return } repository = PlatoonRepository(this, scope) + journal = WeeklyJournalSection(this, work, SAVE, { repository }, ::requestRender) restoreState(savedInstanceState) onBackPressedDispatcher.addCallback(this, editingBack) screen = Screen(this, PrimaryNavigation.Destination.WEEKLY) - buildNoteComposer() - savedInstanceState?.getString(STATE_NOTE_DRAFT)?.let(noteInput::setText) - bottomSections = column() - bottomAdapter.view = column { - setPadding(dp(16), 0, dp(16), dp(24)) - addView(bottomSections, lp()) - } + bottomAdapter.view = journal.view list = RecyclerView(this).apply { id = R.id.screen_list layoutManager = LinearLayoutManager(context) @@ -154,9 +136,7 @@ class WeeklyReportActivity : BaseActivity() { } exports.saveState(outState) if (::scope.isInitialized) edits.save(outState, scope.storageId) - noteDay?.let { outState.putLong(STATE_NOTE_DAY, it.toEpochDay()) } - // The composer lives inside the RecyclerView, which does not save its children's state. - if (::noteInput.isInitialized) outState.putString(STATE_NOTE_DRAFT, noteInput.text.toString()) + if (::journal.isInitialized) journal.saveState(outState) super.onSaveInstanceState(outState) } @@ -170,9 +150,7 @@ class WeeklyReportActivity : BaseActivity() { ) // Edits belong to one Platoon; a profile switch recreates this screen in another scope. edits.restore(state, scope.storageId) - noteDay = state?.takeIf { it.containsKey(STATE_NOTE_DAY) } - ?.getLong(STATE_NOTE_DAY) - ?.let(LocalDate::ofEpochDay) + journal.restoreState(state) } // region Loading and rendering @@ -195,7 +173,7 @@ class WeeklyReportActivity : BaseActivity() { headerAdapter.view = null stickyHeader.visibility = View.GONE rowsAdapter.submit(emptyList()) - bottomSections.removeAllViews() + journal.clear() stateAdapter.view = StateViews.failure(this, getString(R.string.weekly_report_load_failed)) { requestRender() } @@ -257,7 +235,11 @@ class WeeklyReportActivity : BaseActivity() { stickyHeader.addView(weeklyTable.headerRow(report)) } rowsAdapter.submit(next.displayedMembers) - renderBottom(next) + if (edits.isEditing(report.periodStart)) { + journal.clear() + } else { + journal.render(WeeklyJournalSection.Content(report, next.notes, next.events, next.namesByUid, next.zone)) + } updateTopBar() list.post(::updateStickyHeader) } @@ -384,139 +366,6 @@ class WeeklyReportActivity : BaseActivity() { } } - private fun renderBottom(next: RenderModel) { - bottomSections.removeAllViews() - if (edits.isEditing(next.report.periodStart)) return - renderMembershipEvents(next) - bottomSections.addView(sectionHeader(getString(R.string.notes)), lp()) - val notesByDay = next.notes.groupBy(WeeklyNote::gameDay) - if (notesByDay.isEmpty()) { - bottomSections.addView(text(getString(R.string.no_notes), TextRole.SECONDARY) { - setPadding(dp(4), 0, dp(4), dp(8)) - }, lp()) - } else { - bottomSections.addView(panel(padding = 8) { - next.report.days.filter { it in notesByDay }.forEachIndexed { index, day -> - if (index > 0) addView(divider(), lp(top = 4, bottom = 4)) - addView(text(Labels.dayHeader(context, day), TextRole.LABEL) { - setPadding(dp(8), dp(4), dp(8), 0) - }, lp()) - notesByDay[day].orEmpty().forEach { note -> addView(noteRow(note), lp()) } - } - }, lp()) - } - bottomSections.addView(noteComposerPanel(next.report), lp(top = 8)) - } - - private fun renderMembershipEvents(next: RenderModel) { - val events = MembershipEventPresentation.deduplicate(next.events) - bottomSections.addView(sectionHeader(getString(R.string.join_withdraw)), lp()) - if (events.isEmpty()) { - bottomSections.addView(text(getString(R.string.no_membership_events), TextRole.SECONDARY) { - setPadding(dp(4), 0, dp(4), dp(8)) - }, lp()) - return - } - val byDay = events.groupBy { MembershipEventPresentation.calendarDate(it, next.zone) } - val ordered: List = next.report.days.filter { it in byDay } + - if (null in byDay) listOf(null) else emptyList() - bottomSections.addView(panel(padding = 8) { - ordered.forEachIndexed { index, day -> - if (index > 0) addView(divider(), lp(top = 4, bottom = 4)) - addView(text(day?.let { Labels.dayHeader(context, it) } ?: getString(R.string.unknown_date), TextRole.LABEL) { - setPadding(dp(8), dp(4), dp(8), 0) - }, lp()) - byDay[day].orEmpty().forEach { event -> addView(eventRow(event, next), lp()) } - } - }, lp()) - } - - private fun eventRow(event: MemberEvent, next: RenderModel): View { - val joined = event.type == MemberEventType.JOINED || event.type == MemberEventType.REJOINED - val label = getString(if (joined) R.string.join_label else R.string.withdraw_label) - val name = next.namesByUid[event.uid].orEmpty().ifBlank { event.note } - val identity = name.takeIf(String::isNotBlank)?.let { getString(R.string.member_name_uid, it, event.uid) } - ?: Labels.uid(this, event.uid) - val time = MembershipEventPresentation.timePrefix(event, next.zone) - return row { - minimumHeight = dp(40) - setPadding(dp(8), 0, dp(8), 0) - isFocusable = true - contentDescription = listOfNotNull(label, time, identity).joinToString(". ") - addView(icon( - if (joined) R.drawable.ic_arrow_down else R.drawable.ic_arrow_up, - if (joined) R.color.success_mark else R.color.failure_mark, - sizeDp = 18, - ), lp(18, 18, end = 8)) - addView(text(label, TextRole.LABEL) { - setTextColor(getColor(if (joined) R.color.success_text else R.color.destructive_action)) - minWidth = dp(44) - }, lp(WRAP, WRAP, end = 8)) - addView(text(listOfNotNull(time, identity).joinToString(" "), TextRole.BODY) { - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - }, lp(width = 0, weight = 1f)) - } - } - - private fun noteRow(note: WeeklyNote): View = row { - minimumHeight = dp(48) - setPadding(dp(8), 0, 0, 0) - addView(text(note.text, TextRole.BODY) { setTextIsSelectable(true) }, lp(width = 0, weight = 1f)) - if (!note.isAutomatic) { - addView( - iconButton(R.drawable.ic_delete, getString(R.string.delete_note_description, note.text.take(40)), R.color.text_secondary) { - confirmNoteDeletion(note) - }, - lp(48, 48), - ) - } - } - - private fun buildNoteComposer() { - noteInput = textField(getString(R.string.note_hint), id = R.id.weekly_note_input, singleLine = false, maxLength = MAX_NOTE_LENGTH) - noteDayButton = text(null, TextRole.BODY) { - gravity = Gravity.CENTER_VERTICAL - minHeight = dp(48) - setPadding(dp(12), 0, dp(8), 0) - background = selectableRect() - setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_today, 0, R.drawable.ic_expand_more, 0) - compoundDrawablePadding = dp(8) - compoundDrawableTintList = android.content.res.ColorStateList.valueOf(getColor(R.color.text_secondary)) - } - } - - private fun noteComposerPanel(report: WeeklyReportBuilder.Report): View { - val today = model?.let { PlatoonPeriods.gameDay(Instant.now(), it.zone) } - if (noteDay !in report.days) noteDay = today?.takeIf { it in report.days } ?: report.days.first() - updateNoteDay() - noteDayButton.setOnClickListener { - Dialogs.choose( - this, - getString(R.string.note_day), - report.days.map { Labels.dayHeader(this, it) }, - report.days.indexOf(noteDay), - ) { - noteDay = report.days[it] - updateNoteDay() - } - } - (noteInput.parent as? ViewGroup)?.removeView(noteInput) - (noteDayButton.parent as? ViewGroup)?.removeView(noteDayButton) - return panel(padding = 12) { - addView(text(getString(R.string.add_note), TextRole.SUBTITLE), lp()) - addView(noteDayButton, lp(top = 8)) - addView(noteInput, lp(top = 8)) - addView(button(getString(R.string.add_note), Controls.Role.PRIMARY) { addNote(report) }, lp(top = 8)) - } - } - - private fun updateNoteDay() { - val day = noteDay ?: return - noteDayButton.text = Labels.dayHeader(this, day) - noteDayButton.contentDescription = Labels.labeled(this, getString(R.string.note_day), noteDayButton.text) - } - // endregion // region Top bar and actions @@ -704,45 +553,6 @@ class WeeklyReportActivity : BaseActivity() { } } - private fun addNote(report: WeeklyReportBuilder.Report) { - val text = noteInput.text.toString().trim() - val day = noteDay ?: return - if (work.isBusy(SAVE)) return - if (text.isEmpty()) { - noteInput.error = getString(R.string.note_required) - return - } - work.run( - key = SAVE, - task = { repository.addWeeklyNote(report.periodStart.toEpochDay(), day.toEpochDay(), text) }, - ) { result -> - result.fold( - onSuccess = { - noteInput.text = null - Feedback.show(this, R.string.note_added) - requestRender() - }, - onFailure = { error -> - Feedback.show( - this, - if (error is WeeklyNoteLimitException) R.string.weekly_note_limit_reached else R.string.save_failed, - ) - }, - ) - } - } - - private fun confirmNoteDeletion(note: WeeklyNote) { - Dialogs.confirm(this, getString(R.string.delete_note), note.text, getString(R.string.delete), destructive = true) { - work.run(task = { repository.deleteWeeklyNote(note.id) }) { result -> - if (result.getOrDefault(false)) { - Feedback.show(this, R.string.note_deleted) - requestRender() - } - } - } - } - // endregion private inner class RowsAdapter : RecyclerView.Adapter() { @@ -796,9 +606,6 @@ class WeeklyReportActivity : BaseActivity() { const val STANDARD_ROW = 0 const val GUNSMOKE_ROW = 1 const val SAVE = "save" - const val MAX_NOTE_LENGTH = 1_000 const val STATE_REFERENCE_DAY = "weekly.reference_day" - const val STATE_NOTE_DAY = "weekly.note_day" - const val STATE_NOTE_DRAFT = "weekly.note_draft" } } From e06e8581e6e40520d7e47576a4e8ae4c885576c8 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:09:56 +0900 Subject: [PATCH 105/125] fix: keep restored reset-zone overrides consistent with the profile region A complete restore wrote the archived effective region as an explicit override, and a later Platoon-only restore could change the profile region while that override kept the old zone. Restores now restore an archived profile region as automatic, clear the override when a Platoon-only restore changes the region, and journal the exact timezone keys so rollback no longer turns automatic into explicit. --- .../PlatoonBackupManagerIntegrationTest.kt | 108 ++++++++++++++++++ .../app/management/PlatoonBackupManager.kt | 31 ++++- .../PlatoonProfileRestoreJournal.kt | 32 +++++- .../app/management/RestoredTimeZonePolicy.kt | 27 +++++ .../PlatoonProfileRestoreJournalTest.kt | 52 +++++++++ .../management/RestoredTimeZonePolicyTest.kt | 47 ++++++++ docs/ARCHITECTURE.md | 9 +- docs/PLATOON_MANAGEMENT.md | 8 +- 8 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/management/RestoredTimeZonePolicy.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/RestoredTimeZonePolicyTest.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 0ad026f..f2c1000 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -17,6 +17,7 @@ import dev.gf2log.app.settings.AppSettingsStore import dev.gf2log.app.settings.BackupSettingsStore import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.ScopedAppSettingsStore import dev.gf2log.app.settings.WeeklyCutlines import dev.gf2log.protocol.GuildMembersCsv @@ -743,6 +744,113 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(context.getDatabasePath(restoredScope.databaseName).exists()) } + @Test + fun completeRestoreOfAnAutomaticResetRegionKeepsItFollowingTheProfile() { + seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") + assertTrue(GameTimeZonePreferences.isAutomatic(context, storageScope.storageId)) + val archive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).exportFull(it) + }.toByteArray() + + PlatoonBackupManager(context).restoreFull(ByteArrayInputStream(archive)) + + // The archive records only the effective region, which equals the profile region, so + // no explicit override is fabricated that a later server change would leave stale. + assertTrue(GameTimeZonePreferences.isAutomatic(context, storageScope.storageId)) + PlatoonProfileAdministration(context) { _, _, _ -> } + .changeServerRegion(storageScope.storageId, GameServerRegion.HAOPLAY_GLOBAL) + assertTimeZoneFollows(GameServerRegion.HAOPLAY_GLOBAL) + } + + @Test + fun platoonOnlyRestoreThatChangesTheServerRegionClearsTheStaleOverride() { + seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") + val registry = PlatoonProfileRegistry(context) + registry.updateServerRegion(storageScope.storageId, GameServerRegion.HAOPLAY_GLOBAL) + val archive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).export(it) + }.toByteArray() + registry.updateServerRegion(storageScope.storageId, GameServerRegion.HAOPLAY_KOREA) + // An override for the previous region, as an earlier complete restore wrote it. + GameTimeZonePreferences.setRegion(context, GameServerRegion.HAOPLAY_KOREA, storageScope.storageId) + + PlatoonBackupManager(context).restore(ByteArrayInputStream(archive)) + + assertEquals( + GameServerRegion.HAOPLAY_GLOBAL, + registry.find(storageScope.storageId)?.serverRegion, + ) + assertTimeZoneFollows(GameServerRegion.HAOPLAY_GLOBAL) + } + + @Test + fun platoonOnlyRestoreThatKeepsTheServerRegionKeepsAnExplicitOverride() { + seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") + val archive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).export(it) + }.toByteArray() + GameTimeZonePreferences.setRegion(context, GameServerRegion.HAOPLAY_GLOBAL, storageScope.storageId) + + PlatoonBackupManager(context).restore(ByteArrayInputStream(archive)) + + assertFalse(GameTimeZonePreferences.isAutomatic(context, storageScope.storageId)) + assertEquals( + GameServerRegion.HAOPLAY_GLOBAL, + GameTimeZonePreferences.region(context, storageScope.storageId), + ) + } + + @Test + fun interruptedRestoresPutTheExactTimeZoneKeysBack() { + seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.csv") + val registry = PlatoonProfileRegistry(context) + registry.updateServerRegion(storageScope.storageId, GameServerRegion.HAOPLAY_GLOBAL) + val platoonArchive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).export(it) + }.toByteArray() + registry.updateServerRegion(storageScope.storageId, GameServerRegion.HAOPLAY_KOREA) + val completeArchive = ByteArrayOutputStream().also { + PlatoonBackupManager(context).exportFull(it) + }.toByteArray() + val dying = PlatoonBackupManager( + context = context, + settingsStore = settingsStore, + restoreObserver = { checkpoint -> + if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.PROFILE_METADATA_INSTALLED) { + throw SimulatedProcessDeath() + } + }, + storageScope = storageScope, + ) + + // An automatic region survives an interrupted complete restore as automatic. + val automatic = GameTimeZonePreferences.scopedState(context, storageScope.storageId) + assertThrows(SimulatedProcessDeath::class.java) { + dying.restoreFull(ByteArrayInputStream(completeArchive)) + } + PlatoonBackupManager.recoverInterruptedFullRestore(context) + assertEquals(automatic, GameTimeZonePreferences.scopedState(context, storageScope.storageId)) + + // An explicit override cleared by a region-changing restore comes back on rollback. + GameTimeZonePreferences.setRegion(context, GameServerRegion.HAOPLAY_JAPAN, storageScope.storageId) + val explicit = GameTimeZonePreferences.scopedState(context, storageScope.storageId) + assertThrows(SimulatedProcessDeath::class.java) { + dying.restore(ByteArrayInputStream(platoonArchive)) + } + PlatoonBackupManager.recoverInterruptedFullRestore(context) + assertEquals(explicit, GameTimeZonePreferences.scopedState(context, storageScope.storageId)) + assertEquals( + GameServerRegion.HAOPLAY_KOREA, + registry.find(storageScope.storageId)?.serverRegion, + ) + } + + private fun assertTimeZoneFollows(region: GameServerRegion) { + assertTrue(GameTimeZonePreferences.isAutomatic(context, storageScope.storageId)) + assertEquals(region, GameTimeZonePreferences.region(context, storageScope.storageId)) + assertEquals(region.serverZone, GameTimeZonePreferences.get(context, storageScope.storageId)) + } + private fun replaceDatabaseWithCurrentState() { replaceDatabaseForCheckpoint(CURRENT_UID, "Current member", "current-source.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 e65c2aa..ef36663 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -9,6 +9,7 @@ import dev.gf2log.app.settings.AppBackupSettingsCodec import dev.gf2log.app.settings.BackupSettingsStore import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.app.settings.ScopedAppSettingsStore import java.io.EOFException import java.io.File @@ -161,6 +162,7 @@ class PlatoonBackupManager internal constructor( val previousActiveStorageId = registry.active()?.storageId val clientRegions = ClientServerRegionPreferences(appContext) val previousCaptureRegion = clientRegions.stored(restoredProfile.client.packageName) + val previousTimeZone = GameTimeZonePreferences.scopedStateOrNull(appContext, scope.storageId) val manager = if (scope == storageScope) { this } else { @@ -178,6 +180,7 @@ class PlatoonBackupManager internal constructor( previousActiveStorageId = previousActiveStorageId, registry = registry, previousCaptureRegion = previousCaptureRegion, + previousTimeZone = previousTimeZone, ) } @@ -188,6 +191,7 @@ class PlatoonBackupManager internal constructor( val previousActiveStorageId: String?, val registry: PlatoonProfileRegistry, val previousCaptureRegion: GameServerRegion?, + val previousTimeZone: GameTimeZonePreferences.ScopedZoneState?, ) { fun rollbackJournal(): PlatoonProfileRestoreJournal = PlatoonProfileRestoreJournal( targetStorageId = manager.storageScope.storageId, @@ -195,15 +199,28 @@ class PlatoonBackupManager internal constructor( previousActiveStorageId = previousActiveStorageId, ownerPackage = restoredProfile?.client?.packageName, previousCaptureRegion = previousCaptureRegion, + previousTimeZone = previousTimeZone, ) - fun installProfileMetadataAndActivate() { - val storageId = requireNotNull(restoredProfile) - .let(registry::upsertRestored) - .storageId + /** Installs the archived profile, then reconciles its reset-zone override with it. */ + fun installProfileMetadataAndActivate(restoredSettings: Boolean) { + val profile = requireNotNull(restoredProfile) + val storageId = registry.upsertRestored(profile).storageId check(registry.setActive(storageId)) { "Unable to select the restored Platoon" } + val context = manager.appContext + val zoneState = GameTimeZonePreferences.scopedState(context, storageId) + if ( + RestoredTimeZonePolicy.clearsOverride( + state = zoneState, + previousRegion = previousProfile?.serverRegion, + restoredRegion = profile.serverRegion, + restoredSettings = restoredSettings, + ) + ) { + GameTimeZonePreferences.clearRegionOverride(context, storageId) + } } } @@ -258,7 +275,7 @@ class PlatoonBackupManager internal constructor( ) restoreObserver(RestoreCheckpoint.RETAINED_CSV_RETIRED) } - restoreTarget?.installProfileMetadataAndActivate() + restoreTarget?.installProfileMetadataAndActivate(restoredSettings != null) if (restoreTarget != null) { restoreObserver(RestoreCheckpoint.PROFILE_METADATA_INSTALLED) } @@ -676,6 +693,10 @@ class PlatoonBackupManager internal constructor( previousProfile = rollback.previousProfile, previousActiveStorageId = rollback.previousActiveStorageId, ) + // Runs after the settings replay above, which can only write effective values. + rollback.previousTimeZone?.let { state -> + GameTimeZonePreferences.restoreScopedState(context, rollback.targetStorageId, state) + } } cleanupRestoreTransaction(context, scope) } diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt index 39077f5..8feb284 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt @@ -1,19 +1,26 @@ package dev.gf2log.app.management import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream import java.time.Instant +import java.time.ZoneId -/** Durable rollback state for profile metadata changed by a scoped backup restore. */ +/** + * Durable rollback state for profile metadata changed by a scoped backup restore. The exact + * scoped timezone keys are kept because settings rollback can only replay effective values, + * which would turn an automatic reset region into an explicit override. + */ internal data class PlatoonProfileRestoreJournal( val targetStorageId: String, val previousProfile: PlatoonProfile?, val previousActiveStorageId: String?, val ownerPackage: String?, val previousCaptureRegion: GameServerRegion?, + val previousTimeZone: GameTimeZonePreferences.ScopedZoneState? = null, ) internal object PlatoonProfileRestoreJournalCodec { @@ -36,6 +43,11 @@ internal object PlatoonProfileRestoreJournalCodec { output.writeLong(profile.lastSeenAt.toEpochMilli()) output.writeBoolean(profile.legacy) } + output.writeBoolean(value.previousTimeZone != null) + value.previousTimeZone?.let { state -> + output.writeNullable(state.region) + output.writeNullable(state.zone) + } } bytes.toByteArray() } @@ -43,7 +55,7 @@ internal object PlatoonProfileRestoreJournalCodec { fun decode(bytes: ByteArray): PlatoonProfileRestoreJournal = DataInputStream(ByteArrayInputStream(bytes)).use { input -> val version = input.readInt() - require(version in setOf(LEGACY_VERSION, VERSION)) { + require(version in LEGACY_VERSION..VERSION) { "Unsupported profile restore journal" } val targetStorageId = input.readUTF() @@ -84,6 +96,18 @@ internal object PlatoonProfileRestoreJournalCodec { } else { null } + val previousTimeZone = if (version >= TIME_ZONE_VERSION && input.readBoolean()) { + GameTimeZonePreferences.ScopedZoneState( + region = input.readNullable()?.also { + require(GameServerRegion.fromStored(it).storedValue == it) { + "Profile restore journal contains an unknown server region" + } + }, + zone = input.readNullable()?.also { ZoneId.of(it) }, + ) + } else { + null + } require(input.read() == -1) { "Profile restore journal contains trailing data" } PlatoonProfileRestoreJournal( targetStorageId, @@ -91,6 +115,7 @@ internal object PlatoonProfileRestoreJournalCodec { previousActive, ownerPackage, previousRegion, + previousTimeZone, ) } @@ -108,6 +133,7 @@ internal object PlatoonProfileRestoreJournalCodec { } private const val LEGACY_VERSION = 1 - private const val VERSION = 2 + private const val TIME_ZONE_VERSION = 3 + private const val VERSION = TIME_ZONE_VERSION private const val LEGACY_MAX_EMBLEM_PARTS = 32 } diff --git a/app/src/main/java/dev/gf2log/app/management/RestoredTimeZonePolicy.kt b/app/src/main/java/dev/gf2log/app/management/RestoredTimeZonePolicy.kt new file mode 100644 index 0000000..d274f4d --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/RestoredTimeZonePolicy.kt @@ -0,0 +1,27 @@ +package dev.gf2log.app.management + +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences + +/** + * Keeps a restored profile's server region and its scoped reset-zone override consistent. A + * complete backup records only the effective reset region, so an override equal to the restored + * profile region is the archived automatic choice and is cleared instead of becoming explicit. A + * Platoon-only restore that changes the profile region drops the override like a server change + * in Settings does, so the old region's zone cannot outlive the region it was chosen for. + */ +internal object RestoredTimeZonePolicy { + fun clearsOverride( + state: GameTimeZonePreferences.ScopedZoneState, + previousRegion: GameServerRegion?, + restoredRegion: GameServerRegion, + restoredSettings: Boolean, + ): Boolean { + if (state.region == null && state.zone == null) return false + return if (restoredSettings) { + state.region != null && GameServerRegion.fromStored(state.region) == restoredRegion + } else { + previousRegion != restoredRegion + } + } +} diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt index 6b39b6d..a78bf07 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt @@ -1,10 +1,12 @@ package dev.gf2log.app.management import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences import java.io.ByteArrayOutputStream import java.io.DataOutputStream import java.time.Instant import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class PlatoonProfileRestoreJournalTest { @@ -37,6 +39,56 @@ class PlatoonProfileRestoreJournalTest { )) } + @Test + fun journalRoundTripsTheExactScopedTimeZoneKeys() { + val storageId = PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + ) + listOf( + GameTimeZonePreferences.ScopedZoneState(region = null, zone = null), + GameTimeZonePreferences.ScopedZoneState(GameServerRegion.HAOPLAY_JAPAN.storedValue, null), + GameTimeZonePreferences.ScopedZoneState(GameServerRegion.MANUAL.storedValue, "Asia/Seoul"), + null, + ).forEach { state -> + val value = PlatoonProfileRestoreJournal( + targetStorageId = storageId, + previousProfile = null, + previousActiveStorageId = null, + ownerPackage = null, + previousCaptureRegion = null, + previousTimeZone = state, + ) + + assertEquals(value, PlatoonProfileRestoreJournalCodec.decode( + PlatoonProfileRestoreJournalCodec.encode(value), + )) + } + } + + @Test + fun versionTwoJournalDecodesWithoutTimeZoneRollback() { + val storageId = PlatoonProfileIdentity.storageId( + PlatoonClient.HAOPLAY, + GameServerRegion.HAOPLAY_KOREA, + 101817L, + ) + val bytes = ByteArrayOutputStream().use { target -> + DataOutputStream(target).use { output -> + output.writeInt(2) + output.writeUTF(storageId) + output.writeBoolean(false) + output.writeBoolean(false) + output.writeBoolean(false) + output.writeBoolean(false) + } + target.toByteArray() + } + + assertNull(PlatoonProfileRestoreJournalCodec.decode(bytes).previousTimeZone) + } + @Test fun legacyJournalRemainsReadableWithoutMisclassifyingOldListsAsBannerIds() { val storageId = PlatoonProfileIdentity.storageId( diff --git a/app/src/test/java/dev/gf2log/app/management/RestoredTimeZonePolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/RestoredTimeZonePolicyTest.kt new file mode 100644 index 0000000..0734501 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/RestoredTimeZonePolicyTest.kt @@ -0,0 +1,47 @@ +package dev.gf2log.app.management + +import dev.gf2log.app.settings.GameServerRegion +import dev.gf2log.app.settings.GameTimeZonePreferences.ScopedZoneState +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RestoredTimeZonePolicyTest { + private val korea = GameServerRegion.HAOPLAY_KOREA + private val global = GameServerRegion.HAOPLAY_GLOBAL + private val automatic = ScopedZoneState(region = null, zone = null) + private val explicitKorea = ScopedZoneState(region = korea.storedValue, zone = null) + private val manual = ScopedZoneState(region = GameServerRegion.MANUAL.storedValue, zone = "Asia/Seoul") + + @Test + fun `an automatic region never needs clearing`() { + assertFalse(RestoredTimeZonePolicy.clearsOverride(automatic, korea, global, restoredSettings = false)) + assertFalse(RestoredTimeZonePolicy.clearsOverride(automatic, korea, korea, restoredSettings = true)) + } + + @Test + fun `a complete restore turns the archived profile region back into automatic`() { + assertTrue(RestoredTimeZonePolicy.clearsOverride(explicitKorea, null, korea, restoredSettings = true)) + assertTrue(RestoredTimeZonePolicy.clearsOverride(explicitKorea, global, korea, restoredSettings = true)) + } + + @Test + fun `a complete restore keeps a deliberately different or manual archived choice`() { + assertFalse(RestoredTimeZonePolicy.clearsOverride(explicitKorea, korea, global, restoredSettings = true)) + assertFalse(RestoredTimeZonePolicy.clearsOverride(manual, korea, korea, restoredSettings = true)) + } + + @Test + fun `a platoon-only restore clears every override when it changes the profile region`() { + assertTrue(RestoredTimeZonePolicy.clearsOverride(explicitKorea, korea, global, restoredSettings = false)) + assertTrue(RestoredTimeZonePolicy.clearsOverride(manual, korea, global, restoredSettings = false)) + // Keys left by an earlier scope with no registered profile cannot describe this one. + assertTrue(RestoredTimeZonePolicy.clearsOverride(manual, null, global, restoredSettings = false)) + } + + @Test + fun `a platoon-only restore leaves settings alone when the profile region is unchanged`() { + assertFalse(RestoredTimeZonePolicy.clearsOverride(explicitKorea, korea, korea, restoredSettings = false)) + assertFalse(RestoredTimeZonePolicy.clearsOverride(manual, global, global, restoredSettings = false)) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1f8f66d..62b65a7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -297,8 +297,13 @@ from two clients cannot be combined into a false completion. CSV preview and apply retain the same immutable scope even if an Activity is recreated. A scoped backup is validated before profile metadata changes. Its database, settings, retained CSV retirement, registry metadata, active profile, -and client-region routing then commit under one durable journal; interrupted -restores roll all of those resources back together. +client-region routing, and the scoped reset-zone keys then commit under one +durable journal; interrupted restores roll all of those resources back +together. The journal keeps the exact previous timezone keys because replaying +effective settings would turn an automatic region into an explicit one. +`RestoredTimeZonePolicy` decides, after the profile is installed, whether the +restored override is redundant (complete restore) or stale (Platoon-only +restore that changed the server region) and clears it. Profile server edits retain the immutable scope and rebuild weekly history under the repository's exclusive database gate. Full profile deletion uses a durable diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 0ad6011..8998aa1 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -44,7 +44,13 @@ rules below take precedence over stale copied dates in archived templates. When Settings holds an explicit capture server for that client, the dialog preselects it; the user still confirms before anything is stored. - Changing a profile's server rebuilds its weekly history without changing its - storage scope. Deleting a profile removes only that scope after two warnings + storage scope. A restore keeps the profile's server and its reset region + consistent: a complete backup stores only the effective reset region, so a + region equal to the archived profile's server is restored as automatic rather + than as an explicit override, and a Platoon-only restore that changes the + profile's server clears any reset-region or manual-zone override, as a server + change in Settings does. An interrupted restore puts the exact previous + timezone keys back. Deleting a profile removes only that scope after two warnings and an exact Platoon-name confirmation; interrupted cleanup resumes later. ## Merit calculation From d5add44aa8c4ce46c228f3fb61079cc9a1bd1bf6 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:10:30 +0900 Subject: [PATCH 106/125] refactor: extract Home packet history into its own section --- .../java/dev/gf2log/app/HomePacketHistory.kt | 326 ++++++++++++++++++ .../main/java/dev/gf2log/app/MainActivity.kt | 289 +--------------- 2 files changed, 333 insertions(+), 282 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/HomePacketHistory.kt diff --git a/app/src/main/java/dev/gf2log/app/HomePacketHistory.kt b/app/src/main/java/dev/gf2log/app/HomePacketHistory.kt new file mode 100644 index 0000000..737198b --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/HomePacketHistory.kt @@ -0,0 +1,326 @@ +package dev.gf2log.app + +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import dev.gf2log.app.history.CaptureHistoryStore +import dev.gf2log.app.history.SavedHistoryStore +import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.app.ui.AccessibleRow +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.divider +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.panel +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.sectionHeader +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import java.io.File + +/** + * Home's parsed-packet history: the recent and saved tabs, a collapsed list, and a long-press + * selection mode that saves or deletes entries. Back leaves selection before leaving Home. + */ +internal class HomePacketHistory( + private val activity: ComponentActivity, + private val work: BackgroundWork, + private val scope: PlatoonStorageScope?, +) { + val view: LinearLayout = activity.column() + + private val historyStore: CaptureHistoryStore? + private val savedStore: SavedHistoryStore? + private var showingSaved = false + private var selecting = false + private val selectedIds = linkedSetOf() + private var showAll = false + private var recent = emptyList() + private var saved = emptyList() + private var selectionCount: TextView? = null + private val selectionButtons = mutableListOf() + + private val exitSelection = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() = setSelecting(false) + } + + init { + val root = scope?.rootDirectory(activity) + historyStore = root?.let { CaptureHistoryStore(File(it, CaptureHistoryStore.HISTORY_DIRECTORY)) } + savedStore = root?.let { SavedHistoryStore(File(it, SavedHistoryStore.SAVED_HISTORY_DIRECTORY)) } + activity.onBackPressedDispatcher.addCallback(activity, exitSelection) + } + + fun restoreState(state: Bundle?) { + state ?: return + showingSaved = state.getBoolean(STATE_SHOWING_SAVED) + showAll = state.getBoolean(STATE_SHOW_ALL) + selecting = state.getBoolean(STATE_SELECTING) + state.getStringArrayList(STATE_SELECTED)?.let(selectedIds::addAll) + exitSelection.isEnabled = selecting + } + + fun saveState(outState: Bundle) { + outState.putBoolean(STATE_SHOWING_SAVED, showingSaved) + outState.putBoolean(STATE_SHOW_ALL, showAll) + outState.putBoolean(STATE_SELECTING, selecting) + outState.putStringArrayList(STATE_SELECTED, ArrayList(selectedIds)) + } + + fun load() { + val recentStore = historyStore + val savedEntries = savedStore + if (recentStore == null || savedEntries == null) { + render() + return + } + work.run(key = LOAD, task = { recentStore.list() to savedEntries.list() }) { result -> + result.getOrNull()?.let { (recentEntries, savedList) -> + recent = recentEntries + saved = savedList + val ids = (if (showingSaved) saved else recent).mapTo(mutableSetOf()) { it.id } + selectedIds.retainAll(ids) + } + render() + } + } + + private fun render(): Unit = with(activity) { + view.removeAllViews() + val entries = if (showingSaved) saved else recent + view.addView(row { + addView(sectionHeader(getString(R.string.packet_history_section)), lp(width = 0, weight = 1f)) + if (entries.isNotEmpty()) { + addView( + button(getString(if (selecting) R.string.done else R.string.select), Controls.Role.TERTIARY) { + setSelecting(!selecting) + }, + lp(WRAP, WRAP, top = 8), + ) + } + }, lp()) + view.addView(Controls.segmentedControl( + context = this, + options = listOf( + RECENT to getString(R.string.recent_packets_tab, recent.size, CaptureHistoryStore.MAX_ENTRIES), + SAVED to getString(R.string.saved_packets_tab, saved.size, SavedHistoryStore.MAX_ENTRIES), + ), + selectedValue = if (showingSaved) SAVED else RECENT, + ) { value -> + showingSaved = value == SAVED + showAll = false + setSelecting(false) + }, lp()) + if (entries.isEmpty()) { + view.addView(text( + getString( + when { + scope == null -> R.string.no_platoon_history + showingSaved -> R.string.no_saved_packets + else -> R.string.no_parsed_packets + }, + ), + TextRole.SECONDARY, + ) { setPadding(dp(4), dp(16), dp(4), dp(8)) }, lp()) + return@with + } + if (selecting) view.addView(selectionActions(), lp(top = 8)) + val visible = if (showAll) entries else entries.take(COLLAPSED_COUNT) + view.addView(panel(padding = 4) { + visible.forEachIndexed { index, entry -> + if (index > 0) addView(divider(insetStartDp = 12)) + addView(historyRow(entry), lp()) + } + }, lp(top = 8)) + if (!showAll && entries.size > COLLAPSED_COUNT) { + view.addView(button(getString(R.string.show_all_count, entries.size), Controls.Role.TERTIARY) { + showAll = true + render() + }, lp(top = 4)) + } + } + + private fun historyRow(entry: CaptureHistoryStore.Entry): View = with(activity) { + val tag = Labels.payloadTag(this, entry.payloadType) + val mark = icon(R.drawable.ic_circle_outline, R.color.outline_strong, sizeDp = 22) + val row = AccessibleRow(this) + fun refresh() { + val isSelected = selecting && entry.id in selectedIds + row.isSelected = isSelected + mark.setImageResource(if (isSelected) R.drawable.ic_check_circle else R.drawable.ic_circle_outline) + mark.imageTintList = ColorStateList.valueOf( + getColor(if (isSelected) R.color.accent_text else R.color.outline_strong), + ) + row.contentDescription = listOfNotNull( + getString(R.string.selected).takeIf { isSelected }, + entry.title, + getString( + R.string.payload_tag_description, + tag, + entry.payloadType?.toString() ?: getString(R.string.unknown_payload_type), + ), + ).joinToString(". ") + } + row.apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(52) + setPadding(dp(12), dp(4), dp(8), dp(4)) + background = selectableRect() + if (selecting) addView(mark, lp(22, 22, end = 12)) + addView(text(entry.title, TextRole.BODY) { + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + }, lp(width = 0, weight = 1f)) + addView(badge(tag).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(WRAP, WRAP, start = 8)) + if (!selecting) addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) + refresh() + setOnClickListener { + if (selecting) { + // Update in place so accessibility focus stays on this row. + if (!selectedIds.add(entry.id)) selectedIds.remove(entry.id) + refresh() + updateSelectionBar() + } else { + open(entry) + } + } + setOnLongClickListener { + selectedIds.add(entry.id) + if (selecting) { + refresh() + updateSelectionBar() + } else { + setSelecting(true) + } + true + } + } + } + + private fun selectionActions(): View = with(activity) { + row { + selectionButtons.clear() + selectionCount = text(null, TextRole.LABEL) + addView(selectionCount, lp(width = 0, weight = 1f, start = 4)) + if (!showingSaved) { + addView(button(getString(R.string.save_selected_history_short), Controls.Role.SECONDARY, R.drawable.ic_save) { + saveSelected() + }.also(selectionButtons::add), lp(WRAP, WRAP, end = 8)) + } + addView(button(getString(R.string.delete), Controls.Role.DESTRUCTIVE_TEXT, R.drawable.ic_delete) { + confirmDeleteSelected() + }.also(selectionButtons::add), lp(WRAP, WRAP)) + updateSelectionBar() + } + } + + private fun updateSelectionBar() { + selectionCount?.text = activity.resources.getQuantityString(R.plurals.selected_count, selectedIds.size, selectedIds.size) + selectionButtons.forEach { it.isEnabled = selectedIds.isNotEmpty() } + } + + private fun setSelecting(enabled: Boolean) { + selecting = enabled + if (!enabled) selectedIds.clear() + exitSelection.isEnabled = enabled + render() + } + + private fun open(entry: CaptureHistoryStore.Entry) { + activity.startActivity( + Intent(activity, PacketHistoryActivity::class.java) + .putExtra(PacketHistoryActivity.EXTRA_ENTRY_ID, entry.id) + .putExtra(PacketHistoryActivity.EXTRA_ENTRY_TITLE, entry.title) + .putExtra(PacketHistoryActivity.EXTRA_SAVED_ENTRY, showingSaved) + .putExtra(PacketHistoryActivity.EXTRA_STORAGE_ID, scope?.storageId), + ) + } + + private fun saveSelected() { + val source = historyStore ?: return + val target = savedStore ?: return + if (work.isBusy(WRITE)) return + val ids = selectedIds.toList() + work.run(key = WRITE, task = { target.saveFrom(source, ids) }) { result -> + val saveResult = result.getOrNull() + val resources = activity.resources + Feedback.show( + activity, + when { + saveResult == null -> activity.getString(R.string.status_save_history_failed) + saveResult.saved > 0 && saveResult.limitReached -> resources.getQuantityString( + R.plurals.status_saved_history_at_limit, + saveResult.saved, + saveResult.saved, + SavedHistoryStore.MAX_ENTRIES, + ) + saveResult.saved > 0 -> + resources.getQuantityString(R.plurals.status_saved_history, saveResult.saved, saveResult.saved) + saveResult.limitReached -> activity.getString(R.string.status_saved_history_limit, SavedHistoryStore.MAX_ENTRIES) + else -> activity.getString(R.string.status_history_already_saved) + }, + ) + setSelecting(false) + load() + } + } + + private fun confirmDeleteSelected() { + val ids = selectedIds.toList() + val fromSaved = showingSaved + Dialogs.confirm( + activity, + activity.getString(R.string.delete_history_title), + activity.resources.getQuantityString(R.plurals.delete_history_message, ids.size, ids.size), + activity.getString(R.string.delete), + destructive = true, + ) confirm@{ + if (work.isBusy(WRITE)) return@confirm + work.run( + key = WRITE, + task = { (if (fromSaved) savedStore?.delete(ids) else historyStore?.delete(ids)) ?: 0 }, + ) { result -> + val deleted = result.getOrDefault(0) + Feedback.show( + activity, + activity.resources.getQuantityString( + if (fromSaved) R.plurals.status_deleted_saved_history else R.plurals.status_deleted_history, + deleted, + deleted, + ), + ) + setSelecting(false) + load() + } + } + } + + private companion object { + const val LOAD = "history" + const val WRITE = "history_write" + const val RECENT = "recent" + const val SAVED = "saved" + const val COLLAPSED_COUNT = 20 + const val STATE_SHOWING_SAVED = "home.showing_saved" + const val STATE_SHOW_ALL = "home.show_all" + const val STATE_SELECTING = "home.selecting" + const val STATE_SELECTED = "home.selected" + } +} diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index fd5ee01..c7c9dff 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -16,7 +16,6 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView -import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import dev.gf2log.app.capture.CaptureNotice import dev.gf2log.app.capture.CapturePhase @@ -24,18 +23,14 @@ import dev.gf2log.app.capture.CaptureSnapshot import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.capture.CaptureVpnService import dev.gf2log.app.capture.format -import dev.gf2log.app.history.CaptureHistoryStore -import dev.gf2log.app.history.SavedHistoryStore import dev.gf2log.app.management.CsvImportCheckpointManager import dev.gf2log.app.management.PlatoonCsvImportStore import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotComparison import dev.gf2log.app.ui.Controls -import dev.gf2log.app.ui.AccessibleRow import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.CaptureGuard import dev.gf2log.app.ui.BarAction -import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.DocumentAccess import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels @@ -45,7 +40,6 @@ import dev.gf2log.app.ui.WRAP import dev.gf2log.app.ui.badge import dev.gf2log.app.ui.button import dev.gf2log.app.ui.column -import dev.gf2log.app.ui.divider import dev.gf2log.app.ui.dp import dev.gf2log.app.ui.group import dev.gf2log.app.ui.icon @@ -55,11 +49,9 @@ import dev.gf2log.app.ui.setShownInGroup import dev.gf2log.app.ui.panel import dev.gf2log.app.ui.row import dev.gf2log.app.ui.sectionHeader -import dev.gf2log.app.ui.selectableRect import dev.gf2log.app.ui.text import dev.gf2log.protocol.GuildMembersCsv import dev.gf2log.protocol.Gfl2PayloadDecoder -import java.io.File import java.time.ZoneId /** @@ -72,8 +64,7 @@ class MainActivity : BaseActivity() { private lateinit var screen: Screen private val work = BackgroundWork(this, "GF2Home") private lateinit var csvImport: CsvImportFlow - private var historyStore: CaptureHistoryStore? = null - private var savedStore: SavedHistoryStore? = null + private lateinit var packetHistory: HomePacketHistory private lateinit var capturePanel: LinearLayout private lateinit var stateDot: View @@ -85,16 +76,9 @@ class MainActivity : BaseActivity() { private lateinit var undoRow: View private lateinit var progressBanner: LinearLayout private lateinit var progressText: TextView - private lateinit var historySection: LinearLayout private lateinit var rosterSummary: LinearLayout private var captureOnceRequested = false - private var showingSaved = false - private var selecting = false - private val selectedIds = linkedSetOf() - private var showAll = false - private var recent = emptyList() - private var saved = emptyList() private var lastPhase: CapturePhase? = null private val statusListener: (CaptureSnapshot) -> Unit = ::renderCapture @@ -125,10 +109,6 @@ class MainActivity : BaseActivity() { } } - private val exitSelection = object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() = setSelecting(false) - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (!OnboardingPreferences.isCompleted(this)) { @@ -137,21 +117,11 @@ class MainActivity : BaseActivity() { return } profileBinding = ActivePlatoonScopeBinding(this) - profileBinding.scope?.rootDirectory(this)?.let { root -> - historyStore = CaptureHistoryStore(File(root, CaptureHistoryStore.HISTORY_DIRECTORY)) - savedStore = SavedHistoryStore(File(root, SavedHistoryStore.SAVED_HISTORY_DIRECTORY)) - } + packetHistory = HomePacketHistory(this, work, profileBinding.scope) + packetHistory.restoreState(savedInstanceState) csvImport = CsvImportFlow(this, work, { profileBinding.scope }, ::showProgress) csvImport.restoreState(savedInstanceState) - savedInstanceState?.let { - captureOnceRequested = it.getBoolean(STATE_CAPTURE_ONCE) - showingSaved = it.getBoolean(STATE_SHOWING_SAVED) - showAll = it.getBoolean(STATE_SHOW_ALL) - selecting = it.getBoolean(STATE_SELECTING) - it.getStringArrayList(STATE_SELECTED)?.let(selectedIds::addAll) - } - onBackPressedDispatcher.addCallback(this, exitSelection) - exitSelection.isEnabled = selecting + captureOnceRequested = savedInstanceState?.getBoolean(STATE_CAPTURE_ONCE) == true screen = Screen(this, PrimaryNavigation.Destination.HOME) screen.topBar .setLeading(ImageView(this).apply { @@ -182,7 +152,7 @@ class MainActivity : BaseActivity() { return } if (::clients.isInitialized) renderClients() - loadHistory() + packetHistory.load() loadRosterSummary() loadUndoAvailability() csvImport.onScreenReady() @@ -190,11 +160,8 @@ class MainActivity : BaseActivity() { override fun onSaveInstanceState(outState: Bundle) { if (::csvImport.isInitialized) csvImport.saveState(outState) + if (::packetHistory.isInitialized) packetHistory.saveState(outState) outState.putBoolean(STATE_CAPTURE_ONCE, captureOnceRequested) - outState.putBoolean(STATE_SHOWING_SAVED, showingSaved) - outState.putBoolean(STATE_SHOW_ALL, showAll) - outState.putBoolean(STATE_SELECTING, selecting) - outState.putStringArrayList(STATE_SELECTED, ArrayList(selectedIds)) super.onSaveInstanceState(outState) } @@ -234,8 +201,7 @@ class MainActivity : BaseActivity() { startActivity(Intent(this@MainActivity, OptionsActivity::class.java)) }, )), lp()) - historySection = column() - addView(historySection, lp()) + addView(packetHistory.view, lp()) undoRow.setShownInGroup(false) } @@ -520,250 +486,9 @@ class MainActivity : BaseActivity() { } } - // region Packet history - - private fun loadHistory() { - val recentStore = historyStore - val savedEntries = savedStore - if (recentStore == null || savedEntries == null) { - renderHistory() - return - } - work.run(key = HISTORY, task = { recentStore.list() to savedEntries.list() }) { result -> - result.getOrNull()?.let { (recentEntries, savedList) -> - recent = recentEntries - saved = savedList - val ids = (if (showingSaved) saved else recent).mapTo(mutableSetOf()) { it.id } - selectedIds.retainAll(ids) - } - renderHistory() - } - } - - private fun renderHistory() { - historySection.removeAllViews() - val entries = if (showingSaved) saved else recent - historySection.addView(row { - addView(sectionHeader(getString(R.string.packet_history_section)), lp(width = 0, weight = 1f)) - if (entries.isNotEmpty()) { - addView( - button(getString(if (selecting) R.string.done else R.string.select), Controls.Role.TERTIARY) { - setSelecting(!selecting) - }, - lp(WRAP, WRAP, top = 8), - ) - } - }, lp()) - historySection.addView(Controls.segmentedControl( - context = this, - options = listOf( - RECENT to getString(R.string.recent_packets_tab, recent.size, CaptureHistoryStore.MAX_ENTRIES), - SAVED to getString(R.string.saved_packets_tab, saved.size, SavedHistoryStore.MAX_ENTRIES), - ), - selectedValue = if (showingSaved) SAVED else RECENT, - ) { value -> - showingSaved = value == SAVED - showAll = false - setSelecting(false) - }, lp()) - if (entries.isEmpty()) { - historySection.addView(text( - getString( - when { - profileBinding.scope == null -> R.string.no_platoon_history - showingSaved -> R.string.no_saved_packets - else -> R.string.no_parsed_packets - }, - ), - TextRole.SECONDARY, - ) { setPadding(dp(4), dp(16), dp(4), dp(8)) }, lp()) - return - } - if (selecting) historySection.addView(selectionActions(), lp(top = 8)) - val visible = if (showAll) entries else entries.take(COLLAPSED_COUNT) - historySection.addView(panel(padding = 4) { - visible.forEachIndexed { index, entry -> - if (index > 0) addView(divider(insetStartDp = 12)) - addView(historyRow(entry), lp()) - } - }, lp(top = 8)) - if (!showAll && entries.size > COLLAPSED_COUNT) { - historySection.addView(button(getString(R.string.show_all_count, entries.size), Controls.Role.TERTIARY) { - showAll = true - renderHistory() - }, lp(top = 4)) - } - } - - private fun historyRow(entry: CaptureHistoryStore.Entry): View { - val tag = Labels.payloadTag(this, entry.payloadType) - val mark = icon(R.drawable.ic_circle_outline, R.color.outline_strong, sizeDp = 22) - val row = AccessibleRow(this) - fun refresh() { - val isSelected = selecting && entry.id in selectedIds - row.isSelected = isSelected - mark.setImageResource(if (isSelected) R.drawable.ic_check_circle else R.drawable.ic_circle_outline) - mark.imageTintList = android.content.res.ColorStateList.valueOf( - getColor(if (isSelected) R.color.accent_text else R.color.outline_strong), - ) - row.contentDescription = listOfNotNull( - getString(R.string.selected).takeIf { isSelected }, - entry.title, - getString( - R.string.payload_tag_description, - tag, - entry.payloadType?.toString() ?: getString(R.string.unknown_payload_type), - ), - ).joinToString(". ") - } - return row.apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - minimumHeight = dp(52) - setPadding(dp(12), dp(4), dp(8), dp(4)) - background = selectableRect() - if (selecting) addView(mark, lp(22, 22, end = 12)) - addView(text(entry.title, TextRole.BODY) { - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - }, lp(width = 0, weight = 1f)) - addView(badge(tag).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(WRAP, WRAP, start = 8)) - if (!selecting) addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) - refresh() - setOnClickListener { - if (selecting) { - // Update in place so accessibility focus stays on this row. - if (!selectedIds.add(entry.id)) selectedIds.remove(entry.id) - refresh() - updateSelectionBar() - } else { - openEntry(entry) - } - } - setOnLongClickListener { - selectedIds.add(entry.id) - if (selecting) { - refresh() - updateSelectionBar() - } else { - setSelecting(true) - } - true - } - } - } - - private var selectionCount: TextView? = null - private val selectionButtons = mutableListOf() - - private fun selectionActions(): View = row { - selectionButtons.clear() - selectionCount = text(null, TextRole.LABEL) - addView(selectionCount, lp(width = 0, weight = 1f, start = 4)) - if (!showingSaved) { - addView(button(getString(R.string.save_selected_history_short), Controls.Role.SECONDARY, R.drawable.ic_save) { - saveSelected() - }.also(selectionButtons::add), lp(WRAP, WRAP, end = 8)) - } - addView(button(getString(R.string.delete), Controls.Role.DESTRUCTIVE_TEXT, R.drawable.ic_delete) { - confirmDeleteSelected() - }.also(selectionButtons::add), lp(WRAP, WRAP)) - updateSelectionBar() - } - - private fun updateSelectionBar() { - selectionCount?.text = resources.getQuantityString(R.plurals.selected_count, selectedIds.size, selectedIds.size) - selectionButtons.forEach { it.isEnabled = selectedIds.isNotEmpty() } - } - - private fun setSelecting(enabled: Boolean) { - selecting = enabled - if (!enabled) selectedIds.clear() - exitSelection.isEnabled = enabled - renderHistory() - } - - private fun openEntry(entry: CaptureHistoryStore.Entry) { - startActivity( - Intent(this, PacketHistoryActivity::class.java) - .putExtra(PacketHistoryActivity.EXTRA_ENTRY_ID, entry.id) - .putExtra(PacketHistoryActivity.EXTRA_ENTRY_TITLE, entry.title) - .putExtra(PacketHistoryActivity.EXTRA_SAVED_ENTRY, showingSaved) - .putExtra(PacketHistoryActivity.EXTRA_STORAGE_ID, profileBinding.scope?.storageId), - ) - } - - private fun saveSelected() { - val source = historyStore ?: return - val target = savedStore ?: return - if (work.isBusy(HISTORY_WRITE)) return - val ids = selectedIds.toList() - work.run(key = HISTORY_WRITE, task = { target.saveFrom(source, ids) }) { result -> - val saveResult = result.getOrNull() - Feedback.show( - this, - when { - saveResult == null -> getString(R.string.status_save_history_failed) - saveResult.saved > 0 && saveResult.limitReached -> resources.getQuantityString( - R.plurals.status_saved_history_at_limit, - saveResult.saved, - saveResult.saved, - SavedHistoryStore.MAX_ENTRIES, - ) - saveResult.saved > 0 -> - resources.getQuantityString(R.plurals.status_saved_history, saveResult.saved, saveResult.saved) - saveResult.limitReached -> getString(R.string.status_saved_history_limit, SavedHistoryStore.MAX_ENTRIES) - else -> getString(R.string.status_history_already_saved) - }, - ) - setSelecting(false) - loadHistory() - } - } - - private fun confirmDeleteSelected() { - val ids = selectedIds.toList() - val fromSaved = showingSaved - Dialogs.confirm( - this, - getString(R.string.delete_history_title), - resources.getQuantityString(R.plurals.delete_history_message, ids.size, ids.size), - getString(R.string.delete), - destructive = true, - ) confirm@{ - if (work.isBusy(HISTORY_WRITE)) return@confirm - work.run( - key = HISTORY_WRITE, - task = { (if (fromSaved) savedStore?.delete(ids) else historyStore?.delete(ids)) ?: 0 }, - ) { result -> - val deleted = result.getOrDefault(0) - Feedback.show( - this, - resources.getQuantityString( - if (fromSaved) R.plurals.status_deleted_saved_history else R.plurals.status_deleted_history, - deleted, - deleted, - ), - ) - setSelecting(false) - loadHistory() - } - } - } - - // endregion - private companion object { - const val HISTORY = "history" const val SUMMARY = "summary" const val UNDO = "undo" - const val HISTORY_WRITE = "history_write" - const val RECENT = "recent" - const val SAVED = "saved" - const val COLLAPSED_COUNT = 20 const val STATE_CAPTURE_ONCE = "home.capture_once" - const val STATE_SHOWING_SAVED = "home.showing_saved" - const val STATE_SHOW_ALL = "home.show_all" - const val STATE_SELECTING = "home.selecting" - const val STATE_SELECTED = "home.selected" } } From 23e4e090daae57eab29730d34be46af9dc9b5124 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:11:37 +0900 Subject: [PATCH 107/125] refactor: extract the Home capture panel rendering --- .../java/dev/gf2log/app/HomeCapturePanel.kt | 218 ++++++++++++++++++ .../main/java/dev/gf2log/app/MainActivity.kt | 208 +---------------- 2 files changed, 224 insertions(+), 202 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt diff --git a/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt b/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt new file mode 100644 index 0000000..cb8da56 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/HomeCapturePanel.kt @@ -0,0 +1,218 @@ +package dev.gf2log.app + +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import dev.gf2log.app.capture.CaptureNotice +import dev.gf2log.app.capture.CapturePhase +import dev.gf2log.app.capture.CaptureSnapshot +import dev.gf2log.app.capture.CaptureStatus +import dev.gf2log.app.capture.format +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.button +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +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 + * clients, and the start or stop actions. It only renders [CaptureSnapshot]s; the hosting screen + * owns permissions and the capture service. + */ +internal class HomeCapturePanel( + private val context: Context, + private val onStart: (once: Boolean) -> Unit, + private val onStop: () -> Unit, +) { + private val stateDot = View(context).apply { + background = GradientDrawable().apply { shape = GradientDrawable.OVAL } + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + private val stateTitle = context.text(null, TextRole.HEADLINE) { + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + } + private val stateDetail = context.text(null, TextRole.SECONDARY) { maxLines = 4 } + private val checklist = context.column { visibility = View.GONE } + private val captureActions = context.row(Gravity.TOP) + private val clients = context.column() + private var lastPhase: CapturePhase? = null + + val view: LinearLayout = context.panel(outlined = true) { + addView(context.row { + addView(stateDot, context.lp(10, 10, end = 8)) + addView(context.text(context.getString(R.string.capture_status_label), TextRole.LABEL), context.lp(width = 0, weight = 1f)) + }, context.lp()) + addView(stateTitle, context.lp(top = 6)) + addView(stateDetail, context.lp(top = 2)) + addView(checklist, context.lp(top = 8)) + addView(clients, context.lp(top = 12)) + addView(captureActions, context.lp(top = 12)) + } + + init { + renderClients() + } + + /** Rebuilt on every resume so installing or removing a client shows up immediately. */ + fun renderClients() = with(context) { + clients.removeAllViews() + SupportedGamePackages.all.forEachIndexed { index, packageName -> + val installed = isInstalled(packageName) + val client = clientName(packageName) + val status = getString(if (installed) R.string.client_installed else R.string.client_not_installed) + clients.addView(row { + minimumHeight = dp(40) + isFocusable = true + contentDescription = "$client. $packageName. $status" + addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(text(client, TextRole.BODY), lp()) + addView(text(packageName, TextRole.CAPTION) { + typeface = Typeface.MONOSPACE + textSize = 11f + maxLines = 1 + ellipsize = TextUtils.TruncateAt.MIDDLE + }, lp()) + }, lp(width = 0, weight = 1f)) + addView(badge( + status, + // Page-colored fill keeps the badge legible on the green running panel too. + if (installed) R.color.app_background else R.color.surface_variant, + if (installed) R.color.success_text else R.color.text_secondary, + ).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(WRAP, WRAP, start = 8)) + }, lp(top = if (index == 0) 0 else 6)) + } + } + + fun render(snapshot: CaptureSnapshot) = with(context) { + val phase = snapshot.phase + val (title, color) = when (phase) { + CapturePhase.STOPPED -> R.string.capture_ready to R.color.text_primary + CapturePhase.STARTING -> R.string.status_preparing to R.color.accent_text + CapturePhase.RUNNING -> R.string.capture_running to R.color.success_text + } + stateTitle.text = getString(title) + stateTitle.setTextColor(getColor(color)) + // A running capture tints the whole panel so its state reads from across the room. + (view.background as GradientDrawable).apply { + setColor(getColor(if (phase == CapturePhase.RUNNING) R.color.success_surface else R.color.surface)) + setStroke(dp(1), getColor(if (phase == CapturePhase.RUNNING) R.color.success_mark else R.color.outline)) + } + (stateDot.background as GradientDrawable).setColor( + getColor( + when (phase) { + CapturePhase.STOPPED -> R.color.outline_strong + CapturePhase.STARTING -> R.color.accent + CapturePhase.RUNNING -> R.color.success_mark + }, + ), + ) + val notice = snapshot.notice + stateDetail.text = 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) + else -> getString(R.string.capture_preparing_detail) + } + stateDetail.setTextColor( + getColor( + when (notice?.notice?.severity) { + CaptureNotice.Severity.ERROR -> R.color.destructive_action + CaptureNotice.Severity.WARNING -> R.color.warning_text + else -> R.color.text_secondary + }, + ), + ) + renderChecklist(snapshot.guided) + if (phase != lastPhase) { + lastPhase = phase + renderActions(phase) + } + } + + private fun clientNames(packages: List): String { + val names = packages.map(::clientName) + return when (names.size) { + 0 -> context.getString(R.string.none) + 1 -> names.single() + else -> context.getString(R.string.clients_and, names[0], names.drop(1).joinToString(", ")) + } + } + + private fun clientName(packageName: String) = when (packageName) { + SupportedGamePackages.HAOPLAY -> "HaoPlay" + SupportedGamePackages.DARKWINTER -> "Darkwinter" + else -> packageName + } + + private fun renderChecklist(guided: CaptureStatus.GuidedProgress?) = with(context) { + checklist.removeAllViews() + 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()) + } + } + + private fun renderActions(phase: CapturePhase) = with(context) { + captureActions.removeAllViews() + if (phase == CapturePhase.STOPPED) { + captureActions.addView( + button(getString(R.string.capture_one_roster), Controls.Role.PRIMARY, R.drawable.ic_bolt) { onStart(true) }, + lp(width = 0, weight = 1f, end = 4), + ) + captureActions.addView( + button(getString(R.string.prepare_capture), Controls.Role.CAPTURE, R.drawable.ic_play) { onStart(false) }, + lp(width = 0, weight = 1f, start = 4), + ) + } else { + captureActions.addView( + button(getString(R.string.stop_capture), Controls.Role.DESTRUCTIVE, R.drawable.ic_stop) { onStop() }, + lp(), + ) + } + } + + private fun isInstalled(packageName: String): Boolean = runCatching { + val packages = context.packageManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packages.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packages.getPackageInfo(packageName, 0) + } + }.isSuccess +} diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index c7c9dff..810a374 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -3,13 +3,9 @@ package dev.gf2log.app import android.Manifest import android.content.Intent import android.content.pm.PackageManager -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable import android.net.VpnService import android.os.Build import android.os.Bundle -import android.text.TextUtils -import android.view.Gravity import android.view.View import android.widget.FrameLayout import android.widget.ImageView @@ -17,8 +13,6 @@ import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts -import dev.gf2log.app.capture.CaptureNotice -import dev.gf2log.app.capture.CapturePhase import dev.gf2log.app.capture.CaptureSnapshot import dev.gf2log.app.capture.CaptureStatus import dev.gf2log.app.capture.CaptureVpnService @@ -27,7 +21,6 @@ import dev.gf2log.app.management.CsvImportCheckpointManager import dev.gf2log.app.management.PlatoonCsvImportStore import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.SnapshotComparison -import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.CaptureGuard import dev.gf2log.app.ui.BarAction @@ -36,13 +29,9 @@ import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.Screen import dev.gf2log.app.ui.TextRole -import dev.gf2log.app.ui.WRAP -import dev.gf2log.app.ui.badge -import dev.gf2log.app.ui.button import dev.gf2log.app.ui.column import dev.gf2log.app.ui.dp import dev.gf2log.app.ui.group -import dev.gf2log.app.ui.icon import dev.gf2log.app.ui.lp import dev.gf2log.app.ui.navigationRow import dev.gf2log.app.ui.setShownInGroup @@ -51,7 +40,6 @@ import dev.gf2log.app.ui.row import dev.gf2log.app.ui.sectionHeader import dev.gf2log.app.ui.text import dev.gf2log.protocol.GuildMembersCsv -import dev.gf2log.protocol.Gfl2PayloadDecoder import java.time.ZoneId /** @@ -66,22 +54,15 @@ class MainActivity : BaseActivity() { private lateinit var csvImport: CsvImportFlow private lateinit var packetHistory: HomePacketHistory - private lateinit var capturePanel: LinearLayout - private lateinit var stateDot: View - private lateinit var stateTitle: TextView - private lateinit var stateDetail: TextView - private lateinit var checklist: LinearLayout - private lateinit var captureActions: LinearLayout - private lateinit var clients: LinearLayout + private lateinit var capturePanel: HomeCapturePanel private lateinit var undoRow: View private lateinit var progressBanner: LinearLayout private lateinit var progressText: TextView private lateinit var rosterSummary: LinearLayout private var captureOnceRequested = false - private var lastPhase: CapturePhase? = null - private val statusListener: (CaptureSnapshot) -> Unit = ::renderCapture + private val statusListener: (CaptureSnapshot) -> Unit = { capturePanel.render(it) } private val vpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) startCaptureService() else CaptureStatus.markStopped() @@ -138,7 +119,7 @@ class MainActivity : BaseActivity() { override fun onStart() { super.onStart() - if (::stateTitle.isInitialized) CaptureStatus.addListener(statusListener) + if (::capturePanel.isInitialized) CaptureStatus.addListener(statusListener) } override fun onStop() { @@ -151,7 +132,7 @@ class MainActivity : BaseActivity() { recreate() return } - if (::clients.isInitialized) renderClients() + capturePanel.renderClients() packetHistory.load() loadRosterSummary() loadUndoAvailability() @@ -179,7 +160,8 @@ class MainActivity : BaseActivity() { }, lp()) } addView(progressBanner, lp(top = 4, bottom = 8)) - addView(capturePanel(), lp(top = 4)) + capturePanel = HomeCapturePanel(this@MainActivity, ::beginCapture) { CaptureGuard.stop(this@MainActivity) } + addView(capturePanel.view, lp(top = 4)) addView(sectionHeader(getString(R.string.active_platoon_section)), lp()) addView(PlatoonProfileSelector.card(this@MainActivity), lp()) rosterSummary = column { visibility = View.GONE } @@ -207,173 +189,6 @@ class MainActivity : BaseActivity() { // region Capture - private fun capturePanel(): View { - stateDot = View(this).apply { - background = GradientDrawable().apply { shape = GradientDrawable.OVAL } - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - } - stateTitle = text(null, TextRole.HEADLINE) { accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE } - stateDetail = text(null, TextRole.SECONDARY) { maxLines = 4 } - checklist = column { visibility = View.GONE } - captureActions = row(Gravity.TOP) - clients = column() - renderClients() - capturePanel = panel(outlined = true) { - addView(row { - addView(stateDot, lp(10, 10, end = 8)) - addView(text(getString(R.string.capture_status_label), TextRole.LABEL), lp(width = 0, weight = 1f)) - }, lp()) - addView(stateTitle, lp(top = 6)) - addView(stateDetail, lp(top = 2)) - addView(checklist, lp(top = 8)) - addView(clients, lp(top = 12)) - addView(captureActions, lp(top = 12)) - } - return capturePanel - } - - /** Rebuilt on every resume so installing or removing a client shows up immediately. */ - private fun renderClients() = clients.apply { - removeAllViews() - SupportedGamePackages.all.forEachIndexed { index, packageName -> - val installed = isInstalled(packageName) - val client = clientName(packageName) - val status = getString(if (installed) R.string.client_installed else R.string.client_not_installed) - addView(row { - minimumHeight = dp(40) - isFocusable = true - contentDescription = "$client. $packageName. $status" - addView(column { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS - addView(text(client, TextRole.BODY), lp()) - addView(text(packageName, TextRole.CAPTION) { - typeface = Typeface.MONOSPACE - textSize = 11f - maxLines = 1 - ellipsize = TextUtils.TruncateAt.MIDDLE - }, lp()) - }, lp(width = 0, weight = 1f)) - addView(badge( - status, - // Page-colored fill keeps the badge legible on the green running panel too. - if (installed) R.color.app_background else R.color.surface_variant, - if (installed) R.color.success_text else R.color.text_secondary, - ).apply { importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, lp(WRAP, WRAP, start = 8)) - }, lp(top = if (index == 0) 0 else 6)) - } - } - - private fun renderCapture(snapshot: CaptureSnapshot) { - val phase = snapshot.phase - val (title, color) = when (phase) { - CapturePhase.STOPPED -> R.string.capture_ready to R.color.text_primary - CapturePhase.STARTING -> R.string.status_preparing to R.color.accent_text - CapturePhase.RUNNING -> R.string.capture_running to R.color.success_text - } - stateTitle.text = getString(title) - stateTitle.setTextColor(getColor(color)) - // A running capture tints the whole panel so its state reads from across the room. - (capturePanel.background as GradientDrawable).apply { - setColor(getColor(if (phase == CapturePhase.RUNNING) R.color.success_surface else R.color.surface)) - setStroke(dp(1), getColor(if (phase == CapturePhase.RUNNING) R.color.success_mark else R.color.outline)) - } - (stateDot.background as GradientDrawable).setColor( - getColor( - when (phase) { - CapturePhase.STOPPED -> R.color.outline_strong - CapturePhase.STARTING -> R.color.accent - CapturePhase.RUNNING -> R.color.success_mark - }, - ), - ) - val notice = snapshot.notice - stateDetail.text = 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) - else -> getString(R.string.capture_preparing_detail) - } - stateDetail.setTextColor( - getColor( - when (notice?.notice?.severity) { - CaptureNotice.Severity.ERROR -> R.color.destructive_action - CaptureNotice.Severity.WARNING -> R.color.warning_text - else -> R.color.text_secondary - }, - ), - ) - renderChecklist(snapshot.guided) - if (phase != lastPhase) { - lastPhase = phase - renderCaptureActions(phase) - } - } - - private fun clientNames(packages: List): String { - val names = packages.map(::clientName) - return when (names.size) { - 0 -> getString(R.string.none) - 1 -> names.single() - else -> getString(R.string.clients_and, names[0], names.drop(1).joinToString(", ")) - } - } - - private fun clientName(packageName: String) = - if (packageName == SupportedGamePackages.HAOPLAY) "HaoPlay" else if (packageName == SupportedGamePackages.DARKWINTER) "Darkwinter" else packageName - - private fun renderChecklist(guided: CaptureStatus.GuidedProgress?) { - checklist.removeAllViews() - checklist.visibility = if (guided == null) View.GONE else View.VISIBLE - guided ?: return - 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()) - } - } - - private fun renderCaptureActions(phase: CapturePhase) { - captureActions.removeAllViews() - if (phase == CapturePhase.STOPPED) { - captureActions.addView( - button(getString(R.string.capture_one_roster), Controls.Role.PRIMARY, R.drawable.ic_bolt) { - beginCapture(once = true) - }, - lp(width = 0, weight = 1f, end = 4), - ) - captureActions.addView( - button(getString(R.string.prepare_capture), Controls.Role.CAPTURE, R.drawable.ic_play) { - beginCapture(once = false) - }, - lp(width = 0, weight = 1f, start = 4), - ) - } else { - captureActions.addView( - button(getString(R.string.stop_capture), Controls.Role.DESTRUCTIVE, R.drawable.ic_stop) { - stopCaptureService() - }, - lp(), - ) - } - } - private fun beginCapture(once: Boolean) { captureOnceRequested = once if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && @@ -402,17 +217,6 @@ class MainActivity : BaseActivity() { captureOnceRequested = false } - private fun stopCaptureService() = CaptureGuard.stop(this) - - private fun isInstalled(packageName: String): Boolean = runCatching { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) - } else { - @Suppress("DEPRECATION") - packageManager.getPackageInfo(packageName, 0) - } - }.isSuccess - // endregion // region Data tools From ecdc2b804b1bdff1484e225894d03652e4d1d21e Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:12:05 +0900 Subject: [PATCH 108/125] fix: request capture consent once per start tap --- app/src/main/java/dev/gf2log/app/MainActivity.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index 810a374..e430628 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -28,6 +28,7 @@ import dev.gf2log.app.ui.DocumentAccess import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.Screen +import dev.gf2log.app.ui.captureActive import dev.gf2log.app.ui.TextRole import dev.gf2log.app.ui.column import dev.gf2log.app.ui.dp @@ -62,9 +63,13 @@ class MainActivity : BaseActivity() { private var captureOnceRequested = false + /** A start is waiting on the permission or VPN consent screens; further taps are ignored. */ + private var startPending = false + private val statusListener: (CaptureSnapshot) -> Unit = { capturePanel.render(it) } private val vpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + startPending = false if (result.resultCode == RESULT_OK) startCaptureService() else CaptureStatus.markStopped() } @@ -190,6 +195,8 @@ class MainActivity : BaseActivity() { // region Capture private fun beginCapture(once: Boolean) { + if (startPending || captureActive) return + startPending = true captureOnceRequested = once if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED @@ -215,6 +222,7 @@ class MainActivity : BaseActivity() { .putExtra(CaptureVpnService.EXTRA_CAPTURE_ONCE, captureOnceRequested), ) captureOnceRequested = false + startPending = false } // endregion From 6ebb5a87e219efedb8dcb949fb46d5978c471634 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:12:22 +0900 Subject: [PATCH 109/125] fix: bound manual member names and notes where they are stored Custom nicknames and private, membership-period, and weekly notes had no length limit below the input fields. Manual edits now trim and reject a name over 256 characters or a note over 1,000 characters before any row is written. --- .../PlatoonRepositoryIntegrationTest.kt | 51 +++++++ .../gf2log/app/management/ManualTextPolicy.kt | 30 ++++ .../app/management/MembershipEditing.kt | 130 +++++++++--------- .../app/management/WeeklyAnnotationStore.kt | 5 +- .../app/management/ManualTextPolicyTest.kt | 46 +++++++ docs/ARCHITECTURE.md | 6 +- docs/PLATOON_MANAGEMENT.md | 7 + 7 files changed, 209 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt create mode 100644 app/src/test/java/dev/gf2log/app/management/ManualTextPolicyTest.kt 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 8497136..ffff95a 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt @@ -13,6 +13,8 @@ import java.time.ZoneOffset import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -98,6 +100,55 @@ class PlatoonRepositoryIntegrationTest { assertEquals(liveReport(repository, OPENING_WEEK), after) } + @Test + fun manualTextBeyondItsLimitIsRejectedWithoutChangingStoredRows() { + val repository = utcRepository() + val capturedAt = Instant.parse("2026-08-03T12:00:00Z") + repository.ingest(capturedAt, listOf(member(UID, "A")), "a.csv") + val maximumName = "n".repeat(ManualTextPolicy.MAX_MEMBER_NAME_CHARS) + val maximumNote = "x".repeat(ManualTextPolicy.MAX_NOTE_CHARS) + // Limits apply to the stored, trimmed text. + assertTrue(repository.updateMember(UID, " $maximumName ", "\n$maximumNote\n")) + val stored = status(repository, UID) + assertEquals(maximumName, stored.name) + assertEquals(maximumNote, stored.note) + + val longName = maximumName + "n" + val longNote = maximumNote + "x" + val period = stored.membershipPeriods.single() + val joined = MembershipBoundaryValue( + LocalDate.of(2026, 7, 1), + Instant.parse("2026-07-01T12:00:00Z"), + timeKnown = true, + ) + val withdrew = MembershipBoundaryValue( + LocalDate.of(2026, 7, 2), + Instant.parse("2026-07-02T12:00:00Z"), + timeKnown = true, + ) + listOf( + { repository.updateMember(UID, longName, "") }, + { repository.updateMember(UID, "Short", longNote) }, + { repository.updateMembershipPeriod(period.id, joined, null, longNote) }, + { repository.addMembershipPeriod(UID, joined, withdrew, longNote) }, + { repository.addWithdrawnMember(OTHER_UID, longName, joined, withdrew, "") }, + { repository.addWithdrawnMember(OTHER_UID, "Other", joined, withdrew, longNote) }, + { + repository.addWeeklyNote(OPENING_WEEK.toEpochDay(), OPENING_WEEK.toEpochDay(), longNote) + true + }, + ).forEach { edit -> + assertThrows(IllegalArgumentException::class.java) { edit() } + } + + assertEquals(stored, status(repository, UID)) + assertEquals(listOf(UID), repository.listMemberStatuses().map { it.uid }) + assertTrue(repository.listWeeklyNotes(OPENING_WEEK.toEpochDay()).isEmpty()) + } + + private fun status(repository: PlatoonRepository, uid: Long) = + repository.listMemberStatuses().single { it.uid == uid } + private fun utcRepository(): PlatoonRepository { GameTimeZonePreferences.set(context, ZoneOffset.UTC, SCOPE.storageId) return PlatoonRepository(context, SCOPE) diff --git a/app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt b/app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt new file mode 100644 index 0000000..c2eed02 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/management/ManualTextPolicy.kt @@ -0,0 +1,30 @@ +package dev.gf2log.app.management + +import dev.gf2log.protocol.GuildMembersCsv + +/** + * Bounds user-entered text at the persistence boundary. Each value is trimmed exactly as it is + * stored and then rejected, never silently truncated, when it exceeds its limit, so a caller + * that bypasses the input fields cannot store unbounded text and a failed save keeps the user's + * draft intact. Weekly history may still shorten stored text to its own byte budgets. + */ +internal object ManualTextPolicy { + /** The roster member-name limit, which the nickname field also enforces. */ + const val MAX_MEMBER_NAME_CHARS = GuildMembersCsv.MAX_MEMBER_NAME_CHARS + + /** One limit for every manual note, matching the weekly-note input field. */ + const val MAX_NOTE_CHARS = 1_000 + + /** The stored form of a custom member name. */ + fun memberName(value: String): String = value.trim().also { + require(it.isNotEmpty()) { "A member name cannot be blank" } + require(it.length <= MAX_MEMBER_NAME_CHARS) { + "A member name cannot exceed $MAX_MEMBER_NAME_CHARS characters" + } + } + + /** 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/MembershipEditing.kt b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt index 63c5a89..e947f2d 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -21,21 +21,22 @@ internal object MembershipEditing { note: String, ): Boolean { require(uid > 0) - require(name.isNotBlank()) + val storedName = ManualTextPolicy.memberName(name) + val storedNote = ManualTextPolicy.note(note) require(isValidMembershipRange(joined, withdrew)) return db.inTransaction { if (memberExists(db, uid)) return@inTransaction false MembershipRows.insertMember( db = db, uid = uid, - name = name.trim(), + name = storedName, level = 0, firstSeenAt = joined.instant, lastSeenAt = withdrew.instant, - customName = name.trim(), + customName = storedName, ) - val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) - replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, name.trim(), joined, withdrew) + val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, storedNote) + replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, storedName, joined, withdrew) synchronizeAndVerifyMembershipState(db, uid) true } @@ -48,13 +49,14 @@ internal object MembershipEditing { withdrew: MembershipBoundaryValue?, note: String, ): Boolean { + val storedNote = ManualTextPolicy.note(note) require(isValidMembershipRange(joined, withdrew)) return db.inTransaction { val memberName = memberName(db, uid) ?: return@inTransaction false if (!canStoreMembershipPeriod(db, uid, null, joined, withdrew)) { return@inTransaction false } - val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, note) + val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, storedNote) replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, memberName, joined, withdrew) synchronizeAndVerifyMembershipState(db, uid) true @@ -62,10 +64,9 @@ internal object MembershipEditing { } fun updateMember(db: SQLiteDatabase, uid: Long, name: String, note: String): Boolean { - require(name.isNotBlank()) val values = ContentValues().apply { - put("custom_name", name.trim()) - put("note", note.trim()) + put("custom_name", ManualTextPolicy.memberName(name)) + put("note", ManualTextPolicy.note(note)) } return db.update("members", values, "uid = ?", arrayOf(uid.toString())) == 1 } @@ -131,62 +132,65 @@ internal object MembershipEditing { joined: MembershipBoundaryValue, left: MembershipBoundaryValue?, note: String, - ): Boolean = db.inTransaction { - val period = readMembershipPeriodForUpdate(db, membershipPeriodId, gameZone) - ?: return@inTransaction false - // Exact packet-linked boundaries stay immutable; only editable sides take the request. - val effectiveJoined = if (isImmutableMembershipBoundary(period.joined, period.joinedSource)) { - requireNotNull(period.joined) - } else { - joined - } - val effectiveLeft = if (isImmutableMembershipBoundary(period.left, period.leftSource)) { - period.left - } else { - left - } - require(isValidMembershipRange(effectiveJoined, effectiveLeft)) - if (!canStoreMembershipPeriod(db, period.uid, membershipPeriodId, effectiveJoined, effectiveLeft)) { - return@inTransaction false - } - val joinedChanged = editableMembershipBoundaryChanged( - original = period.joined, - requested = joined, - source = period.joinedSource, - ) - val leftChanged = editableMembershipBoundaryChanged( - original = period.left, - requested = left, - source = period.leftSource, - ) - val updated = db.update( - "membership_periods", - ContentValues().apply { - if (joinedChanged) putManualBoundary("joined", joined) - if (leftChanged) putManualBoundary("left", left) - put("note", note.trim()) - }, - "id = ?", - arrayOf(membershipPeriodId.toString()), - ) == 1 - if (updated && (joinedChanged || leftChanged)) { - val joinedIsManual = joinedChanged || period.joinedSource == EvidenceSource.MANUAL - val leftIsManual = if (leftChanged) { - left != null + ): Boolean { + val storedNote = ManualTextPolicy.note(note) + return db.inTransaction { + val period = readMembershipPeriodForUpdate(db, membershipPeriodId, gameZone) + ?: return@inTransaction false + // Exact packet-linked boundaries stay immutable; only editable sides take the request. + val effectiveJoined = if (isImmutableMembershipBoundary(period.joined, period.joinedSource)) { + requireNotNull(period.joined) } else { - period.leftSource == EvidenceSource.MANUAL + joined } - replaceManualMembershipPeriodEvents( - db, - membershipPeriodId, - period.uid, - memberName(db, period.uid).orEmpty(), - effectiveJoined.takeIf { joinedIsManual }, - effectiveLeft.takeIf { leftIsManual }, + val effectiveLeft = if (isImmutableMembershipBoundary(period.left, period.leftSource)) { + period.left + } else { + left + } + require(isValidMembershipRange(effectiveJoined, effectiveLeft)) + if (!canStoreMembershipPeriod(db, period.uid, membershipPeriodId, effectiveJoined, effectiveLeft)) { + return@inTransaction false + } + val joinedChanged = editableMembershipBoundaryChanged( + original = period.joined, + requested = joined, + source = period.joinedSource, + ) + val leftChanged = editableMembershipBoundaryChanged( + original = period.left, + requested = left, + source = period.leftSource, ) + val updated = db.update( + "membership_periods", + ContentValues().apply { + if (joinedChanged) putManualBoundary("joined", joined) + if (leftChanged) putManualBoundary("left", left) + put("note", storedNote) + }, + "id = ?", + arrayOf(membershipPeriodId.toString()), + ) == 1 + if (updated && (joinedChanged || leftChanged)) { + val joinedIsManual = joinedChanged || period.joinedSource == EvidenceSource.MANUAL + val leftIsManual = if (leftChanged) { + left != null + } else { + period.leftSource == EvidenceSource.MANUAL + } + replaceManualMembershipPeriodEvents( + db, + membershipPeriodId, + period.uid, + memberName(db, period.uid).orEmpty(), + effectiveJoined.takeIf { joinedIsManual }, + effectiveLeft.takeIf { leftIsManual }, + ) + } + if (updated) synchronizeAndVerifyMembershipState(db, period.uid) + updated } - if (updated) synchronizeAndVerifyMembershipState(db, period.uid) - updated } /** @@ -264,7 +268,7 @@ internal object MembershipEditing { uid: Long, joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue?, - note: String, + storedNote: String, ): Long = db.insertOrThrow( "membership_periods", null, @@ -272,7 +276,7 @@ internal object MembershipEditing { put("uid", uid) putManualBoundary("joined", joined) putManualBoundary("left", withdrew) - put("note", note.trim()) + put("note", storedNote) }, ) diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt index cf7faf4..215793c 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyAnnotationStore.kt @@ -12,7 +12,8 @@ internal object WeeklyAnnotationStore { gameDayEpochDay: Long, text: String, ): Long { - require(text.isNotBlank()) + val storedText = ManualTextPolicy.note(text) + require(storedText.isNotEmpty()) require( WeeklyNotePolicy.isWithinWeek( LocalDate.ofEpochDay(periodStartEpochDay), @@ -33,7 +34,7 @@ internal object WeeklyAnnotationStore { ContentValues().apply { put("period_start", periodStartEpochDay) put("game_day", gameDayEpochDay) - put("text", text.trim()) + put("text", storedText) putNull("event_id") put("is_automatic", 0) }, diff --git a/app/src/test/java/dev/gf2log/app/management/ManualTextPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/ManualTextPolicyTest.kt new file mode 100644 index 0000000..eaa5846 --- /dev/null +++ b/app/src/test/java/dev/gf2log/app/management/ManualTextPolicyTest.kt @@ -0,0 +1,46 @@ +package dev.gf2log.app.management + +import dev.gf2log.protocol.GuildMembersCsv +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class ManualTextPolicyTest { + @Test + fun `member names keep the roster limit that the nickname field enforces`() { + assertEquals(GuildMembersCsv.MAX_MEMBER_NAME_CHARS, ManualTextPolicy.MAX_MEMBER_NAME_CHARS) + val maximum = "n".repeat(ManualTextPolicy.MAX_MEMBER_NAME_CHARS) + + assertEquals(maximum, ManualTextPolicy.memberName(" $maximum\t")) + assertThrows(IllegalArgumentException::class.java) { + ManualTextPolicy.memberName(maximum + "n") + } + assertThrows(IllegalArgumentException::class.java) { ManualTextPolicy.memberName(" \n ") } + } + + @Test + fun `notes are trimmed before the shared limit is checked`() { + val maximum = "x".repeat(ManualTextPolicy.MAX_NOTE_CHARS) + + assertEquals(maximum, ManualTextPolicy.note("\n$maximum ")) + assertEquals("", ManualTextPolicy.note(" ")) + assertThrows(IllegalArgumentException::class.java) { ManualTextPolicy.note(maximum + "x") } + } + + @Test + fun `the limit counts UTF-16 units like the Android input filters`() { + val emoji = "😀" + val atLimit = emoji.repeat(ManualTextPolicy.MAX_NOTE_CHARS / 2) + + assertEquals(atLimit, ManualTextPolicy.note(atLimit)) + assertThrows(IllegalArgumentException::class.java) { ManualTextPolicy.note(atLimit + emoji) } + } + + @Test + fun `a note at the limit fits the weekly history note budget`() { + // Weekly notes are stored in history up to MAX_NOTE_BYTES; one UTF-16 unit needs at most + // three UTF-8 bytes, so a stored weekly note is never shortened there. + assertTrue(ManualTextPolicy.MAX_NOTE_CHARS * 3 <= WeeklyReportHistoryCodec.MAX_NOTE_BYTES) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 62b65a7..1e6d641 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -224,7 +224,11 @@ index. Schema v13 repairs the derived active-member projection from canonical membership periods during upgrade; supported older backups are upgraded before strict current-schema validation. These limits are enforced again at repository and persistence boundaries so a -future caller cannot bypass the protocol-layer checks. +future caller cannot bypass the protocol-layer checks. User-entered text has +the same treatment: `ManualTextPolicy` trims and then rejects a member name +over 256 characters or a private, membership-period, or weekly note over +1,000 characters inside `MembershipEditing` and `WeeklyAnnotationStore`, +before any row is written. ## HTTPS and application-layer encryption diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 8998aa1..564e647 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -152,6 +152,13 @@ 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. +- 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 + note, or weekly note holds at most 1,000 characters (the weekly-note field + limit). Longer text is rejected and nothing is written, so the draft is never + silently cut. Weekly history may still shorten a private note to its 2 KiB + per-member budget; a 1,000-character weekly note always fits its 16 KiB one. - The first observed roster creates an active membership period with an unknown historical join time unless the user supplies one. - A UID added between consecutive snapshots opens a join or rejoin membership period. From be1508b6b282e318d0af0806bbeba67ece63fd32 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:13:13 +0900 Subject: [PATCH 110/125] refactor: extract the roster row and withdrawn-member dialog --- .../java/dev/gf2log/app/PlatoonActivity.kt | 199 +++--------------- .../java/dev/gf2log/app/PlatoonMemberRow.kt | 111 ++++++++++ .../dev/gf2log/app/WithdrawnMemberDialog.kt | 84 ++++++++ 3 files changed, 224 insertions(+), 170 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt create mode 100644 app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index d7723e3..b8495f4 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -3,12 +3,10 @@ package dev.gf2log.app import android.content.Intent import android.content.res.ColorStateList import android.os.Bundle -import android.text.InputType import android.text.TextUtils import android.view.Gravity import android.view.View import android.view.ViewGroup -import android.widget.CheckBox import android.widget.EditText import android.widget.LinearLayout import android.widget.TextView @@ -24,16 +22,13 @@ import dev.gf2log.app.management.PlatoonMemberCsv import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonSnapshot import dev.gf2log.app.management.SnapshotMember -import dev.gf2log.app.management.isValidMembershipRange import dev.gf2log.app.ui.searchField import dev.gf2log.app.ui.Controls -import dev.gf2log.app.ui.AccessibleRow import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.BarAction import dev.gf2log.app.ui.Dialogs import dev.gf2log.app.ui.DocumentAccess import dev.gf2log.app.ui.Feedback -import dev.gf2log.app.ui.LabeledField import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.MATCH import dev.gf2log.app.ui.MenuEntry @@ -42,19 +37,13 @@ import dev.gf2log.app.ui.SingleViewAdapter import dev.gf2log.app.ui.StateViews import dev.gf2log.app.ui.TextRole import dev.gf2log.app.ui.WRAP -import dev.gf2log.app.ui.badge import dev.gf2log.app.ui.column import dev.gf2log.app.ui.dp -import dev.gf2log.app.ui.icon import dev.gf2log.app.ui.lp import dev.gf2log.app.ui.panel import dev.gf2log.app.ui.row import dev.gf2log.app.ui.selectableRect -import dev.gf2log.app.ui.errorLine -import dev.gf2log.app.ui.showMessage import dev.gf2log.app.ui.text -import dev.gf2log.app.ui.textField -import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId @@ -321,7 +310,11 @@ class PlatoonActivity : BaseActivity() { }), overflow = listOf( MenuEntry(getString(R.string.select_members)) { setSelecting(true) }, - MenuEntry(getString(R.string.add_withdrawn_member)) { showAddWithdrawnMemberDialog() }, + 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.edit_member_order)) { startActivity(Intent(this, MemberOrderActivity::class.java)) }, @@ -351,173 +344,39 @@ class PlatoonActivity : BaseActivity() { exportMembers.launch("GF2logger-members-${Labels.FILE_STAMP.format(LocalDateTime.now())}.csv") } - private fun showAddWithdrawnMemberDialog() { - 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))) - 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 note = LabeledField( - this, - getString(R.string.membership_note_hint), - textField(getString(R.string.membership_note_hint), singleLine = false), - ) - val error = errorLine() - val content = Dialogs.content(this) { - listOf(uid, nickname, joined, withdrew, note, error).forEachIndexed { index, view -> - addView(view, lp(top = if (index == 0) 0 else 12)) - } - } - Dialogs.show( - Dialogs.builder(this) - .setTitle(R.string.add_withdrawn_member) - .setView(content) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.add, null), - ) { dialog -> - dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> - val uidValue = uid.input.text.toString().trim().toLongOrNull()?.takeIf { it > 0 } - val name = nickname.input.text.toString().trim() - val joinedBoundary = joined.boundary - val withdrewBoundary = withdrew.boundary - val problem = when { - uidValue == null -> R.string.invalid_uid - model?.statuses?.any { it.uid == uidValue } == true -> R.string.duplicate_uid - name.isEmpty() -> R.string.invalid_nickname - joinedBoundary == null || withdrewBoundary == null -> R.string.invalid_dates_required - !isValidMembershipRange(joinedBoundary, withdrewBoundary) -> R.string.invalid_date_order - else -> null - } - if (problem != null) { - error.showMessage(getString(problem)) - return@setOnClickListener - } - button.isEnabled = false - val noteText = note.input.text.toString() - work.run( - task = { - repository.addWithdrawnMember( - requireNotNull(uidValue), - name, - requireNotNull(joinedBoundary), - requireNotNull(withdrewBoundary), - noteText, - ) - }, - ) { result -> - button.isEnabled = true - if (result.getOrDefault(false)) { - dialog.dismiss() - Feedback.show(this, R.string.saved) - load() - } else { - error.showMessage(getString(R.string.invalid_withdrawn_member)) - } - } - } - } - } - private fun latestMembers(snapshot: PlatoonSnapshot?): Map = snapshot?.members.orEmpty().associateBy(SnapshotMember::uid) private inner class MemberAdapter : ListAdapter(DIFF) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = MemberHolder() - - override fun onBindViewHolder(holder: MemberHolder, position: Int) = holder.bind(getItem(position)) - } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = MemberHolder(PlatoonMemberRow(this@PlatoonActivity)) - private inner class MemberHolder : RecyclerView.ViewHolder(AccessibleRow(this@PlatoonActivity)) { - private val row = itemView as AccessibleRow - private val check = CheckBox(this@PlatoonActivity).apply { - isClickable = false - isFocusable = false - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - buttonTintList = Controls.controlTint(this@PlatoonActivity) - } - private val name = text(null, TextRole.SUBTITLE) { - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - } - private val status = badge("") - private val detail = text(null, TextRole.CAPTION) { - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - } - private val merit = text(null, TextRole.SUBTITLE) { gravity = Gravity.END } - - init { - row.orientation = LinearLayout.HORIZONTAL - row.gravity = Gravity.CENTER_VERTICAL - row.minimumHeight = dp(64) - row.setPadding(dp(8), dp(8), dp(8), dp(8)) - row.background = selectableRect() - row.layoutParams = RecyclerView.LayoutParams(MATCH, WRAP).apply { bottomMargin = dp(2) } - row.addView(check, lp(WRAP, WRAP, end = 4)) - row.addView(column { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS - addView(row { - addView(name, lp(width = 0, weight = 1f)) - addView(status, lp(WRAP, WRAP, start = 8)) - }, lp()) - addView(detail, lp(top = 2)) - }, lp(width = 0, weight = 1f)) - row.addView(column { - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS - addView(merit, lp()) - addView(text(getString(R.string.merit_this_week_short), TextRole.CAPTION) { gravity = Gravity.END }, lp()) - }, lp(WRAP, WRAP, start = 12)) - row.addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) - } - - fun bind(member: MemberStatus) { + override fun onBindViewHolder(holder: MemberHolder, position: Int) { + val member = getItem(position) val loaded = model ?: return - val latest = loaded.latestMembers[member.uid] - check.visibility = if (selecting) View.VISIBLE else View.GONE - check.isChecked = member.uid in selected - name.text = member.name - status.text = getString(if (member.isActive) R.string.active_member else R.string.departed_member) - status.visibility = if (member.isActive) View.GONE else View.VISIBLE - detail.text = buildList { - add(Labels.uid(this@PlatoonActivity, member.uid)) - add(getString(R.string.level_short, latest?.level ?: member.level)) - latest?.lastLogin?.takeIf { it > 0 }?.let { - add(getString( - R.string.last_login_short, - Labels.format(Labels.DATE_TIME, Instant.ofEpochSecond(it), loaded.zone), - )) - } - }.joinToString(" · ") - merit.text = latest?.weeklyMerit?.toString() ?: "—" - row.isSelected = selecting && member.uid in selected - row.contentDescription = listOfNotNull( - getString(R.string.selected).takeIf { row.isSelected }, - member.name, - status.text, - detail.text, - Labels.labeled(this@PlatoonActivity, getString(R.string.merit_this_week), merit.text), - ).joinToString(". ") - row.setOnClickListener { - if (selecting) { - toggle(member.uid) - } else { - startActivity( - Intent(this@PlatoonActivity, MemberDetailActivity::class.java) - .putExtra(MemberDetailActivity.EXTRA_UID, member.uid), - ) - } - } - row.setOnLongClickListener { - toggle(member.uid) - true - } + holder.row.bind( + member = member, + latest = loaded.latestMembers[member.uid], + zone = loaded.zone, + selecting = selecting, + selected = member.uid in selected, + onClick = { + if (selecting) { + toggle(member.uid) + } else { + startActivity( + Intent(this@PlatoonActivity, MemberDetailActivity::class.java) + .putExtra(MemberDetailActivity.EXTRA_UID, member.uid), + ) + } + }, + onLongClick = { toggle(member.uid) }, + ) } } + private class MemberHolder(val row: PlatoonMemberRow) : RecyclerView.ViewHolder(row.view) + + private data class Model( val latest: PlatoonSnapshot?, val statuses: List, diff --git a/app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt b/app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt new file mode 100644 index 0000000..c0faf7a --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/PlatoonMemberRow.kt @@ -0,0 +1,111 @@ +package dev.gf2log.app + +import android.content.Context +import android.text.TextUtils +import android.view.Gravity +import android.view.View +import android.widget.CheckBox +import android.widget.LinearLayout +import androidx.recyclerview.widget.RecyclerView +import dev.gf2log.app.management.MemberStatus +import dev.gf2log.app.management.SnapshotMember +import dev.gf2log.app.ui.AccessibleRow +import dev.gf2log.app.ui.Controls +import dev.gf2log.app.ui.Labels +import dev.gf2log.app.ui.MATCH +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.WRAP +import dev.gf2log.app.ui.badge +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.dp +import dev.gf2log.app.ui.icon +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.row +import dev.gf2log.app.ui.selectableRect +import dev.gf2log.app.ui.text +import java.time.Instant +import java.time.ZoneId + +/** One recyclable roster row: name, status, level and last login, and this week's merit. */ +internal class PlatoonMemberRow(private val context: Context) { + val view = AccessibleRow(context) + private val check = CheckBox(context).apply { + isClickable = false + isFocusable = false + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + buttonTintList = Controls.controlTint(context) + } + private val name = context.text(null, TextRole.SUBTITLE) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + private val status = context.badge("") + private val detail = context.text(null, TextRole.CAPTION) { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + } + private val merit = context.text(null, TextRole.SUBTITLE) { gravity = Gravity.END } + + init { + with(context) { + view.orientation = LinearLayout.HORIZONTAL + view.gravity = Gravity.CENTER_VERTICAL + view.minimumHeight = dp(64) + view.setPadding(dp(8), dp(8), dp(8), dp(8)) + view.background = selectableRect() + view.layoutParams = RecyclerView.LayoutParams(MATCH, WRAP).apply { bottomMargin = dp(2) } + view.addView(check, lp(WRAP, WRAP, end = 4)) + view.addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(row { + addView(name, lp(width = 0, weight = 1f)) + addView(status, lp(WRAP, WRAP, start = 8)) + }, lp()) + addView(detail, lp(top = 2)) + }, lp(width = 0, weight = 1f)) + view.addView(column { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(merit, lp()) + addView(text(getString(R.string.merit_this_week_short), TextRole.CAPTION) { gravity = Gravity.END }, lp()) + }, lp(WRAP, WRAP, start = 12)) + view.addView(icon(R.drawable.ic_chevron_right, sizeDp = 20), lp(20, 20, start = 4)) + } + } + + fun bind( + member: MemberStatus, + latest: SnapshotMember?, + zone: ZoneId, + selecting: Boolean, + selected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + ) = with(context) { + check.visibility = if (selecting) View.VISIBLE else View.GONE + check.isChecked = selected + name.text = member.name + status.text = getString(if (member.isActive) R.string.active_member else R.string.departed_member) + status.visibility = if (member.isActive) View.GONE else View.VISIBLE + detail.text = buildList { + add(Labels.uid(context, member.uid)) + add(getString(R.string.level_short, latest?.level ?: member.level)) + latest?.lastLogin?.takeIf { it > 0 }?.let { + add(getString(R.string.last_login_short, Labels.format(Labels.DATE_TIME, Instant.ofEpochSecond(it), zone))) + } + }.joinToString(" · ") + merit.text = latest?.weeklyMerit?.toString() ?: "—" + view.isSelected = selecting && selected + view.contentDescription = listOfNotNull( + getString(R.string.selected).takeIf { view.isSelected }, + member.name, + status.text, + detail.text, + Labels.labeled(context, getString(R.string.merit_this_week), merit.text), + ).joinToString(". ") + view.setOnClickListener { onClick() } + view.setOnLongClickListener { + onLongClick() + true + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt b/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt new file mode 100644 index 0000000..8b5339d --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt @@ -0,0 +1,84 @@ +package dev.gf2log.app + +import android.app.Activity +import android.app.AlertDialog +import android.text.InputType +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.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.LabeledField +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.showMessage +import dev.gf2log.app.ui.textField + +/** Records a member who left before capture began, with a required join and withdrawal date. */ +internal object WithdrawnMemberDialog { + fun show( + activity: Activity, + work: BackgroundWork, + repository: PlatoonRepository, + isKnownUid: (Long) -> Boolean, + 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 nickname = LabeledField(this, getString(R.string.member_nickname), textField(getString(R.string.member_nickname))) + 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 note = LabeledField( + this, + getString(R.string.membership_note_hint), + textField(getString(R.string.membership_note_hint), singleLine = false), + ) + val error = errorLine() + val content = Dialogs.content(this) { + listOf(uid, nickname, joined, withdrew, note, error).forEachIndexed { index, view -> + addView(view, lp(top = if (index == 0) 0 else 12)) + } + } + Dialogs.show( + Dialogs.builder(this) + .setTitle(R.string.add_withdrawn_member) + .setView(content) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.add, null), + ) { dialog -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { button -> + val uidValue = uid.input.text.toString().trim().toLongOrNull()?.takeIf { it > 0 } + val name = nickname.input.text.toString().trim() + val joinedBoundary = joined.boundary + val withdrewBoundary = withdrew.boundary + val problem = when { + uidValue == null -> R.string.invalid_uid + isKnownUid(uidValue) -> R.string.duplicate_uid + name.isEmpty() -> R.string.invalid_nickname + joinedBoundary == null || withdrewBoundary == null -> R.string.invalid_dates_required + !isValidMembershipRange(joinedBoundary, withdrewBoundary) -> R.string.invalid_date_order + else -> null + } + if (problem != null || uidValue == null || joinedBoundary == null || withdrewBoundary == null) { + error.showMessage(getString(problem ?: R.string.invalid_withdrawn_member)) + return@setOnClickListener + } + button.isEnabled = false + val noteText = note.input.text.toString() + work.run( + task = { repository.addWithdrawnMember(uidValue, name, joinedBoundary, withdrewBoundary, noteText) }, + ) { result -> + button.isEnabled = true + if (result.getOrDefault(false)) { + dialog.dismiss() + Feedback.show(this, R.string.saved) + onAdded() + } else { + error.showMessage(getString(R.string.invalid_withdrawn_member)) + } + } + } + } + } +} From 8a9ce330e3f3a6809330d05211afae2f9692b558 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:14:27 +0900 Subject: [PATCH 111/125] refactor: extract Settings backup flow and Discord webhook row --- .../main/java/dev/gf2log/app/BackupFlow.kt | 124 ++++++++++++ .../java/dev/gf2log/app/DiscordWebhookRow.kt | 94 +++++++++ .../java/dev/gf2log/app/OptionsActivity.kt | 186 +----------------- 3 files changed, 226 insertions(+), 178 deletions(-) create mode 100644 app/src/main/java/dev/gf2log/app/BackupFlow.kt create mode 100644 app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt diff --git a/app/src/main/java/dev/gf2log/app/BackupFlow.kt b/app/src/main/java/dev/gf2log/app/BackupFlow.kt new file mode 100644 index 0000000..7744cf4 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/BackupFlow.kt @@ -0,0 +1,124 @@ +package dev.gf2log.app + +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import dev.gf2log.app.management.BackupFileName +import dev.gf2log.app.management.InvalidBackupException +import dev.gf2log.app.management.PlatoonBackupManager +import dev.gf2log.app.management.PlatoonStorageScope +import dev.gf2log.app.management.RestoredBackupKind +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.CaptureGuard +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.DocumentAccess +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.Labels +import java.time.LocalDateTime + +/** + * Settings backup actions: export the selected Platoon or everything, and restore a chosen file. + * Each waits for capture to stop and shares [maintenanceKey] with other maintenance so only one + * runs at a time; a successful restore recreates the screen on the restored data. + */ +internal class BackupFlow( + private val activity: ComponentActivity, + private val work: BackgroundWork, + private val maintenanceKey: String, + private val scope: () -> PlatoonStorageScope?, +) { + private var pendingExportComplete = false + + private val exportDocument = activity.registerForActivityResult( + ActivityResultContracts.CreateDocument(PlatoonBackupManager.MIME_TYPE), + ) { uri -> + val target = scope() + if (uri == null || target == null) 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) }, + ) { + DocumentAccess.openOutput(activity.contentResolver, uri).use { output -> + val manager = PlatoonBackupManager(activity, target) + if (complete) manager.exportFull(output) else manager.export(output) + } + } + } + + private val restoreDocument = activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let(::restore) + } + + fun saveState(outState: Bundle) { + outState.putBoolean(STATE_EXPORT_COMPLETE, pendingExportComplete) + } + + fun restoreState(state: Bundle?) { + pendingExportComplete = state?.getBoolean(STATE_EXPORT_COMPLETE) == true + } + + fun export(complete: Boolean) = CaptureGuard.whenStopped(activity, R.string.stop_capture_before_backup) { + pendingExportComplete = complete + val prefix = if (complete) "GF2logger-full" else "GF2logger-platoon" + exportDocument.launch("$prefix-${Labels.FILE_STAMP.format(LocalDateTime.now())}.${PlatoonBackupManager.FILE_EXTENSION}") + } + + fun confirmRestore() = CaptureGuard.whenStopped(activity, R.string.stop_capture_before_backup) { + Dialogs.confirm(activity, R.string.restore_full_backup, R.string.restore_backup_warning, R.string.choose_file, destructive = true) { + restoreDocument.launch(arrayOf(PlatoonBackupManager.MIME_TYPE, "application/zip", "application/octet-stream")) + } + } + + private fun restore(uri: Uri) { + if (DocumentAccess.displayName(activity.contentResolver, uri)?.let(BackupFileName::isValid) != true) { + Feedback.show(activity, R.string.invalid_full_backup, long = true) + return + } + if (work.isBusy(maintenanceKey)) return + Feedback.show(activity, R.string.backup_restoring) + work.run( + key = maintenanceKey, + task = { + DocumentAccess.openInput(activity.contentResolver, uri).use { input -> + 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) }, + ) + } + } + + 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 -> + Feedback.show(activity, result.fold(onSuccess = { success }, onFailure = failure), long = true) + } + } + + private companion object { + const val STATE_EXPORT_COMPLETE = "settings.export_complete" + } +} + +/** Known validation failures blame the selected file; anything else is an operational failure. */ +internal fun fullBackupRestoreFailureMessage(error: Throwable): Int = + if (error is InvalidBackupException) R.string.invalid_full_backup else R.string.full_backup_restore_failed + +/** A Platoon-only export failure must not claim that the complete backup failed. */ +internal fun backupExportFailureMessage(complete: Boolean): Int = + if (complete) R.string.full_backup_export_failed else R.string.status_backup_export_failed + diff --git a/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt b/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt new file mode 100644 index 0000000..ff28cb0 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/DiscordWebhookRow.kt @@ -0,0 +1,94 @@ +package dev.gf2log.app + +import android.app.Activity +import android.app.AlertDialog +import android.text.InputType +import dev.gf2log.app.discord.DiscordWebhookSecretStore +import dev.gf2log.app.ui.BackgroundWork +import dev.gf2log.app.ui.Dialogs +import dev.gf2log.app.ui.Feedback +import dev.gf2log.app.ui.TextRole +import dev.gf2log.app.ui.column +import dev.gf2log.app.ui.errorLine +import dev.gf2log.app.ui.group +import dev.gf2log.app.ui.lp +import dev.gf2log.app.ui.navigationRow +import dev.gf2log.app.ui.showMessage +import dev.gf2log.app.ui.text +import dev.gf2log.app.ui.textField + +/** Settings row for the Discord webhook: shows whether one is stored, and edits or clears it. */ +internal class DiscordWebhookRow(private val activity: Activity, private val work: BackgroundWork) { + val view = activity.column() + + init { + refresh() + } + + private fun refresh() { + work.run(task = { DiscordWebhookSecretStore(activity).read() != null }) { result -> + val configured = result.getOrDefault(false) + view.removeAllViews() + view.addView(activity.group(listOf( + activity.navigationRow( + activity.getString(if (configured) R.string.discord_webhook_configured else R.string.discord_webhook_not_configured), + activity.getString(R.string.discord_webhook_summary), + R.drawable.ic_discord, + trailing = activity.getString(if (configured) R.string.change else R.string.set_up), + ) { edit(configured) }, + )), activity.lp()) + } + } + + private fun edit(configured: Boolean) = with(activity) { + val input = textField( + getString(R.string.discord_webhook_hint), + id = R.id.webhook_input, + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI, + ) + val error = errorLine() + val builder = Dialogs.builder(this) + .setTitle(R.string.discord_webhook) + .setView(Dialogs.content(this) { + addView(text(getString(R.string.discord_webhook_description), TextRole.SECONDARY), lp()) + addView(input, lp(top = 12)) + addView(error, lp(top = 4)) + }) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.save_discord_webhook, null) + if (configured) builder.setNeutralButton(R.string.clear_discord_webhook, null) + Dialogs.show(builder) { dialog -> + 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) { + dialog.dismiss() + Feedback.show(this, R.string.discord_webhook_saved) + refresh() + } else { + error.showMessage(getString(R.string.discord_webhook_invalid)) + } + } + } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.apply { + setTextColor(getColor(R.color.destructive_action)) + setOnClickListener { + if (work.isBusy(WRITE)) return@setOnClickListener + work.run(key = WRITE, task = { DiscordWebhookSecretStore(activity).clear() }) { result -> + dialog.dismiss() + Feedback.show( + activity, + if (result.isSuccess) R.string.discord_webhook_cleared else R.string.discord_webhook_clear_failed, + ) + refresh() + } + } + } + } + } + + private companion object { + const val WRITE = "webhook" + } +} diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index a4e5819..5f92277 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -1,22 +1,13 @@ package dev.gf2log.app -import android.app.AlertDialog import android.content.Intent import android.content.pm.PackageManager -import android.net.Uri import android.os.Build import android.os.Bundle -import android.text.InputType import android.widget.LinearLayout -import androidx.activity.result.contract.ActivityResultContracts import dev.gf2log.app.capture.CaptureDiagnosticsStore -import dev.gf2log.app.discord.DiscordWebhookSecretStore -import dev.gf2log.app.management.BackupFileName -import dev.gf2log.app.management.InvalidBackupException -import dev.gf2log.app.management.PlatoonBackupManager import dev.gf2log.app.management.PlatoonRepository import dev.gf2log.app.management.PlatoonStorageScope -import dev.gf2log.app.management.RestoredBackupKind import dev.gf2log.app.settings.CapturePreferences import dev.gf2log.app.settings.ClientServerRegionPreferences import dev.gf2log.app.settings.GameServerRegion @@ -25,8 +16,6 @@ import dev.gf2log.app.settings.PayloadHistoryPreferences import dev.gf2log.app.ui.Controls import dev.gf2log.app.ui.BackgroundWork import dev.gf2log.app.ui.Dialogs -import dev.gf2log.app.ui.CaptureGuard -import dev.gf2log.app.ui.DocumentAccess import dev.gf2log.app.ui.Feedback import dev.gf2log.app.ui.Labels import dev.gf2log.app.ui.Screen @@ -39,12 +28,8 @@ import dev.gf2log.app.ui.navigationRow import dev.gf2log.app.ui.panel import dev.gf2log.app.ui.sectionHeader import dev.gf2log.app.ui.switchRow -import dev.gf2log.app.ui.errorLine -import dev.gf2log.app.ui.showMessage import dev.gf2log.app.ui.text -import dev.gf2log.app.ui.textField import dev.gf2log.protocol.PayloadCatalog -import java.time.LocalDateTime import java.time.ZoneId /** Settings: grouped rows for the Platoon, display, capture, backup, sharing, and about. */ @@ -52,34 +37,12 @@ class OptionsActivity : BaseActivity() { private lateinit var profileBinding: ActivePlatoonScopeBinding private val work = BackgroundWork(this, "GF2Settings") - private var pendingExportComplete = false - - private val exportBackup = registerForActivityResult( - ActivityResultContracts.CreateDocument(PlatoonBackupManager.MIME_TYPE), - ) { uri -> - val scope = profileBinding.scope - if (uri == null || scope == null) return@registerForActivityResult - val complete = pendingExportComplete - runBackup( - progress = R.string.backup_exporting, - success = if (complete) R.string.full_backup_exported else R.string.status_backup_exported, - failure = { backupExportFailureMessage(complete) }, - ) { - DocumentAccess.openOutput(contentResolver, uri).use { output -> - val manager = PlatoonBackupManager(this, scope) - if (complete) manager.exportFull(output) else manager.export(output) - } - } - } - - private val restoreBackup = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - uri?.let(::restore) - } + private val backup = BackupFlow(this, work, MAINTENANCE) { profileBinding.scope } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) profileBinding = ActivePlatoonScopeBinding(this) - pendingExportComplete = savedInstanceState?.getBoolean(STATE_EXPORT_COMPLETE) == true + backup.restoreState(savedInstanceState) val screen = Screen(this) screen.topBar.setNavigation(::finish).setTitle(getString(R.string.payload_options)) screen.setBody(Screen.scrollingBody(this, buildContent())) @@ -90,7 +53,7 @@ class OptionsActivity : BaseActivity() { } override fun onSaveInstanceState(outState: Bundle) { - outState.putBoolean(STATE_EXPORT_COMPLETE, pendingExportComplete) + backup.saveState(outState) super.onSaveInstanceState(outState) } @@ -187,22 +150,20 @@ class OptionsActivity : BaseActivity() { getString(R.string.export_platoon_backup_detail), R.drawable.ic_download, enabled = scope != null, - ) { startExport(complete = false) }, + ) { backup.export(complete = false) }, navigationRow( getString(R.string.back_up_all_information), getString(R.string.back_up_all_information_description), R.drawable.ic_save, enabled = scope != null, - ) { startExport(complete = true) }, + ) { backup.export(complete = true) }, navigationRow(getString(R.string.restore_full_backup), getString(R.string.restore_backup_detail), R.drawable.ic_upload) { - confirmRestore() + backup.confirmRestore() }, )), lp()) addView(sectionHeader(getString(R.string.discord_webhook)), lp()) - val webhookRow = column() - addView(webhookRow, lp()) - renderWebhookRow(webhookRow) + addView(DiscordWebhookRow(this@OptionsActivity, work).view, lp()) addView(sectionHeader(getString(R.string.settings_about)), lp()) addView(group(listOf( @@ -312,128 +273,7 @@ class OptionsActivity : BaseActivity() { // endregion - // region Backup - - private fun startExport(complete: Boolean) = CaptureGuard.whenStopped(this, R.string.stop_capture_before_backup) { - pendingExportComplete = complete - val prefix = if (complete) "GF2logger-full" else "GF2logger-platoon" - exportBackup.launch("$prefix-${Labels.FILE_STAMP.format(LocalDateTime.now())}.${PlatoonBackupManager.FILE_EXTENSION}") - } - - private fun confirmRestore() = CaptureGuard.whenStopped(this, R.string.stop_capture_before_backup) { - Dialogs.confirm(this, R.string.restore_full_backup, R.string.restore_backup_warning, R.string.choose_file, destructive = true) { - restoreBackup.launch(arrayOf(PlatoonBackupManager.MIME_TYPE, "application/zip", "application/octet-stream")) - } - } - - private fun restore(uri: Uri) { - if (DocumentAccess.displayName(contentResolver, uri)?.let(BackupFileName::isValid) != true) { - Feedback.show(this, R.string.invalid_full_backup, long = true) - return - } - if (work.isBusy(MAINTENANCE)) return - Feedback.show(this, R.string.backup_restoring) - work.run( - key = MAINTENANCE, - task = { - DocumentAccess.openInput(contentResolver, uri).use { input -> - PlatoonBackupManager.restoreSelected(this, input) - } - }, - ) { result -> - result.fold( - onSuccess = { kind -> - Feedback.show( - this, - if (kind == RestoredBackupKind.COMPLETE) R.string.full_backup_restored else R.string.status_backup_restored, - long = true, - ) - recreate() - }, - onFailure = { Feedback.show(this, fullBackupRestoreFailureMessage(it), long = true) }, - ) - } - } - - private fun runBackup( - progress: Int, - success: Int, - failure: (Throwable) -> Int, - operation: () -> Unit, - ) { - if (work.isBusy(MAINTENANCE)) return - Feedback.show(this, progress) - work.run(key = MAINTENANCE, task = operation) { result -> - Feedback.show(this, result.fold(onSuccess = { success }, onFailure = failure), long = true) - } - } - - // endregion - - // region Discord, diagnostics, about - - private fun renderWebhookRow(container: LinearLayout) { - work.run(task = { DiscordWebhookSecretStore(this).read() != null }) { result -> - val configured = result.getOrDefault(false) - container.removeAllViews() - container.addView(group(listOf( - navigationRow( - getString(if (configured) R.string.discord_webhook_configured else R.string.discord_webhook_not_configured), - getString(R.string.discord_webhook_summary), - R.drawable.ic_discord, - trailing = getString(if (configured) R.string.change else R.string.set_up), - ) { editWebhook(container, configured) }, - )), lp()) - } - } - - private fun editWebhook(container: LinearLayout, configured: Boolean) { - val input = textField( - getString(R.string.discord_webhook_hint), - id = R.id.webhook_input, - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI, - ) - val error = errorLine() - val builder = Dialogs.builder(this) - .setTitle(R.string.discord_webhook) - .setView(Dialogs.content(this) { - addView(text(getString(R.string.discord_webhook_description), TextRole.SECONDARY), lp()) - addView(input, lp(top = 12)) - addView(error, lp(top = 4)) - }) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.save_discord_webhook, null) - if (configured) builder.setNeutralButton(R.string.clear_discord_webhook, null) - Dialogs.show(builder) { dialog -> - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val value = input.text.toString() - if (work.isBusy(WEBHOOK)) return@setOnClickListener - work.run(key = WEBHOOK, task = { DiscordWebhookSecretStore(this).save(value) }) { result -> - if (result.isSuccess) { - dialog.dismiss() - Feedback.show(this, R.string.discord_webhook_saved) - renderWebhookRow(container) - } else { - error.showMessage(getString(R.string.discord_webhook_invalid)) - } - } - } - dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.apply { - setTextColor(getColor(R.color.destructive_action)) - setOnClickListener { - if (work.isBusy(WEBHOOK)) return@setOnClickListener - work.run(key = WEBHOOK, task = { DiscordWebhookSecretStore(this@OptionsActivity).clear() }) { result -> - dialog.dismiss() - Feedback.show( - this@OptionsActivity, - if (result.isSuccess) R.string.discord_webhook_cleared else R.string.discord_webhook_clear_failed, - ) - renderWebhookRow(container) - } - } - } - } - } + // region Diagnostics and about private fun showDiagnostics() { work.run(task = { CaptureDiagnosticsStore(this).read() }) { result -> @@ -477,16 +317,6 @@ class OptionsActivity : BaseActivity() { // endregion private companion object { - const val STATE_EXPORT_COMPLETE = "settings.export_complete" const val MAINTENANCE = "maintenance" - const val WEBHOOK = "webhook" } } - -/** Known validation failures blame the selected file; anything else is an operational failure. */ -internal fun fullBackupRestoreFailureMessage(error: Throwable): Int = - if (error is InvalidBackupException) R.string.invalid_full_backup else R.string.full_backup_restore_failed - -/** A Platoon-only export failure must not claim that the complete backup failed. */ -internal fun backupExportFailureMessage(complete: Boolean): Int = - if (complete) R.string.full_backup_export_failed else R.string.status_backup_export_failed From fab1254c1a418dd4d643ffa0bfb546ea47834b37 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:15:03 +0900 Subject: [PATCH 112/125] refactor: drop unused spacer, work cancel, and top-bar title accessor --- app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt | 5 ----- app/src/main/java/dev/gf2log/app/ui/TopBar.kt | 3 --- app/src/main/java/dev/gf2log/app/ui/Views.kt | 5 ----- 3 files changed, 13 deletions(-) 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 2b62b8d..0ffd672 100644 --- a/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt +++ b/app/src/main/java/dev/gf2log/app/ui/BackgroundWork.kt @@ -59,11 +59,6 @@ internal class BackgroundWork(owner: LifecycleOwner, threadName: String) { } } - /** Invalidates pending results for [key] without cancelling work already committing. */ - fun cancel(key: String) { - generations[key] = (generations[key] ?: 0) + 1 - } - private companion object { const val TAG = "GF2Work" } 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 e3e30dc..3efb873 100644 --- a/app/src/main/java/dev/gf2log/app/ui/TopBar.kt +++ b/app/src/main/java/dev/gf2log/app/ui/TopBar.kt @@ -6,7 +6,6 @@ import android.view.Gravity import android.view.View import android.widget.LinearLayout import android.widget.PopupMenu -import android.widget.TextView import androidx.annotation.DrawableRes import dev.gf2log.app.R @@ -63,8 +62,6 @@ internal class TopBar(context: Context) : LinearLayout(context) { addView(actions, context.lp(WRAP, WRAP)) } - val title: TextView get() = titleView - fun setTitle(value: CharSequence?, subtitle: CharSequence? = null): TopBar = apply { titleView.text = value subtitleView.text = subtitle 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 f15a96f..64fad44 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Views.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Views.kt @@ -466,10 +466,5 @@ internal fun Context.selectableBorderless(): android.graphics.drawable.Drawable null, ) -internal fun Context.spacer(heightDp: Int): View = View(this).apply { - layoutParams = LinearLayout.LayoutParams(MATCH, dp(heightDp)) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO -} - internal fun Context.frame(block: FrameLayout.() -> Unit = {}): FrameLayout = FrameLayout(this).apply(block) From 31788c1d05b507d4f36297d9cb3d7125e64c558c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:15:31 +0900 Subject: [PATCH 113/125] fix: skip dialogs requested by a finishing screen --- app/src/main/java/dev/gf2log/app/ui/Dialogs.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 5c241e1..138a854 100644 --- a/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt +++ b/app/src/main/java/dev/gf2log/app/ui/Dialogs.kt @@ -42,10 +42,11 @@ internal object Dialogs { * and registers it with its screen so [dismissAll] closes it on recreation. */ fun showTracked(dialog: D): D { + val owner = owner(dialog.context) + // Background results can arrive after finish(); a leaving screen gets no new window. + if (owner != null && (owner.isFinishing || owner.isDestroyed)) return dialog dialog.show() - owner(dialog.context)?.let { owner -> - open.getOrPut(owner) { mutableListOf() }.add(java.lang.ref.WeakReference(dialog)) - } + owner?.let { open.getOrPut(it) { mutableListOf() }.add(java.lang.ref.WeakReference(dialog)) } return dialog } From ea57ff6d9861a99c0e0279683194cf37202ec424 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:29:48 +0900 Subject: [PATCH 114/125] fix: roll back unrecognized scoped timezone keys verbatim The restore journal rejected a region or zone string that the timezone reader tolerates, so an interrupted restore with such a key could never finish recovering. The journal now replays the raw keys unchanged. --- .../app/management/PlatoonProfileRestoreJournal.kt | 11 ++++------- .../management/PlatoonProfileRestoreJournalTest.kt | 2 ++ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt index 8feb284..374fdb7 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonProfileRestoreJournal.kt @@ -7,7 +7,6 @@ import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream import java.time.Instant -import java.time.ZoneId /** * Durable rollback state for profile metadata changed by a scoped backup restore. The exact @@ -96,14 +95,12 @@ internal object PlatoonProfileRestoreJournalCodec { } else { null } + // The raw keys are rolled back verbatim: the timezone reader already tolerates an + // unknown region or zone, so rejecting one here would only abort recovery. val previousTimeZone = if (version >= TIME_ZONE_VERSION && input.readBoolean()) { GameTimeZonePreferences.ScopedZoneState( - region = input.readNullable()?.also { - require(GameServerRegion.fromStored(it).storedValue == it) { - "Profile restore journal contains an unknown server region" - } - }, - zone = input.readNullable()?.also { ZoneId.of(it) }, + region = input.readNullable(), + zone = input.readNullable(), ) } else { null diff --git a/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt b/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt index a78bf07..ae5dee0 100644 --- a/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/PlatoonProfileRestoreJournalTest.kt @@ -50,6 +50,8 @@ class PlatoonProfileRestoreJournalTest { GameTimeZonePreferences.ScopedZoneState(region = null, zone = null), GameTimeZonePreferences.ScopedZoneState(GameServerRegion.HAOPLAY_JAPAN.storedValue, null), GameTimeZonePreferences.ScopedZoneState(GameServerRegion.MANUAL.storedValue, "Asia/Seoul"), + // Raw keys the timezone reader tolerates must roll back verbatim, not abort recovery. + GameTimeZonePreferences.ScopedZoneState("region_from_a_newer_app", "Not/AZone"), null, ).forEach { state -> val value = PlatoonProfileRestoreJournal( From 4bad722c875ba16e060cced2743debce9683741c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:30:57 +0900 Subject: [PATCH 115/125] fix: let an interrupted restore cleanup finish on the next start Cleanup deleted the settings snapshot before the PREPARED state, and recovery then required the missing snapshot, so a process death there failed recovery on every launch. Cleanup now deletes the state first and recovery treats a missing snapshot as already rolled back. --- .../PlatoonBackupManagerIntegrationTest.kt | 18 ++++++++++++++++++ .../app/management/PlatoonBackupManager.kt | 11 ++++++----- docs/ARCHITECTURE.md | 5 ++++- 3 files changed, 28 insertions(+), 6 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 f2c1000..249b0ff 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -255,6 +255,24 @@ class PlatoonBackupManagerIntegrationTest { assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) } + @Test + fun rollbackInterruptedWhileCleaningUpFinishesOnTheNextStart() { + settingsStore.replace(currentSettings()) + // Older cleanup deleted the settings snapshot before the state, so an interruption there + // left a PREPARED state with only the rollback marker of an already completed rollback. + FilePaths.restoreStateFile(context).apply { + parentFile?.mkdirs() + writeText("PREPARED", Charsets.US_ASCII) + } + java.io.File(FilePaths.restoreTransactionDirectory(context), "settings.rollback_required") + .writeBytes(ByteArray(0)) + + PlatoonBackupManager.recoverInterruptedFullRestore(context) + + assertEquals(currentSettings(), settingsStore.read()) + assertFalse(FilePaths.restoreTransactionDirectory(context).exists()) + } + @Test fun settingsFailureRollsBackDatabaseAndSettingsTogether() { seedDatabase(ARCHIVED_UID, "Archived member", "archived-source.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 ef36663..dac6f2c 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonBackupManager.kt @@ -439,7 +439,6 @@ class PlatoonBackupManager internal constructor( restoreSettingsFile(appContext, storageScope), AppBackupSettingsCodec.encode(previousSettings), ) - writeAtomic(restoreSettingsRollbackFile(appContext, storageScope), ByteArray(0)) } if (!databaseExisted) { writeAtomic(restoreDatabaseWasMissingFile(appContext, storageScope), ByteArray(0)) @@ -474,6 +473,7 @@ class PlatoonBackupManager internal constructor( private const val RESTORE_TRANSACTION_DIRECTORY = "platoon-full-restore" private const val RESTORE_STATE_FILE = "state" private const val RESTORE_SETTINGS_FILE = "settings.pre_restore" + /** Written by older versions after the snapshot; now only removed during cleanup. */ private const val RESTORE_SETTINGS_ROLLBACK_FILE = "settings.rollback_required" private const val RESTORE_DATABASE_WAS_MISSING_FILE = "database.was_missing" private const val RESTORE_PROFILE_FILE = "profile.pre_restore" @@ -659,10 +659,10 @@ class PlatoonBackupManager internal constructor( } } } + // A missing snapshot means nothing to replay: it is written before the restore can + // touch settings, and is deleted only by cleanup after a completed rollback. val settingsFile = restoreSettingsFile(context, scope) - val settingsRollback = restoreSettingsRollbackFile(context, scope) - if (settingsRollback.isFile || settingsFile.isFile) { - require(settingsFile.isFile) { "Previous app settings are missing" } + if (settingsFile.isFile) { settingsStore.replace(AppBackupSettingsCodec.decode(settingsFile.readBytes())) } if (previousCsv.exists()) { @@ -769,9 +769,10 @@ class PlatoonBackupManager internal constructor( val state = restoreStateFile(context, scope) val databaseWasMissing = restoreDatabaseWasMissingFile(context, scope) val profile = restoreProfileFile(context, scope) + // The state goes first: without it, an interrupted cleanup only finishes deleting. + state.delete() settings.delete() settingsRollback.delete() - state.delete() databaseWasMissing.delete() profile.delete() directory.delete() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e6d641..6777f25 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -376,7 +376,10 @@ the repository maintenance lock. The previous database and a settings snapshot are retained until both resources commit, so any failure restores the prior state instead of leaving a partial import. A fresh install records that no database existed so rollback removes the staged replacement rather than -mistaking it for prior state. Schema introspection uses the extended SQLite +mistaking it for prior state. Cleanup deletes the transaction state file +first, so a process death during cleanup leaves only leftovers to delete +rather than a half-cleaned PREPARED transaction that recovery would reject on +every start. Schema introspection uses the extended SQLite column metadata where available and the equivalent ordinary-column metadata on the older SQLite engine shipped with the supported Android 8 minimum. From 7b43c3f07968b543506c0ca83f7f78fb25819108 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:32:11 +0900 Subject: [PATCH 116/125] fix: never merge weekly overrides into a pair storage rejects Editing Login to no over a stored Daily Patrol yes, or the reverse, produced an attended=false, dailyPatrol=true override that replaceWeeklyOverrides refuses, failing the whole save. The edited choice now settles the contradiction, and contradictory choices in one edit are reported as invalid. --- .../app/management/WeeklyOverrideEditing.kt | 22 ++++++- .../management/WeeklyOverrideEditingTest.kt | 64 +++++++++++++++++++ docs/PLATOON_MANAGEMENT.md | 5 ++ 3 files changed, 88 insertions(+), 3 deletions(-) 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 46c15e2..44265b0 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyOverrideEditing.kt @@ -10,7 +10,8 @@ import java.time.LocalDate * set for the week, because the repository replaces a week's overrides as one unit. */ object WeeklyOverrideEditing { - enum class Field { MERIT, SCORE, ATTEMPTS } + /** The invalid input; [ACTIVITY] is a Login NO with a Daily Patrol YES in the same edit. */ + enum class Field { MERIT, SCORE, ATTEMPTS, ACTIVITY } /** A tri-state activity choice: unset clears the manual value, yes/no confirms it. */ enum class Choice(val value: Boolean?) { @@ -59,6 +60,21 @@ object WeeklyOverrideEditing { val merit = edit?.merit?.let { parseCounter(it) ?: return invalid(Field.MERIT) } val score = edit?.score?.let { parseCounter(it) ?: 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) + } + // A completed Daily Patrol requires attendance, so an edited choice settles a + // contradiction with the other stored one instead of producing an unstorable pair. + val attended = when { + edit?.attended != null -> edit.attended.value + edit?.dailyPatrol == Choice.YES && stored?.attended == false -> true + else -> stored?.attended + } + val dailyPatrol = when { + edit?.dailyPatrol != null -> edit.dailyPatrol.value + attended == false && stored?.dailyPatrol == true -> false + else -> stored?.dailyPatrol + } WeeklyCellOverride( uid = cell.first, periodStart = periodStart, @@ -74,8 +90,8 @@ object WeeklyOverrideEditing { edit?.attempts != null -> attempts?.value else -> stored?.attempts }, - attended = if (edit?.attended != null) edit.attended.value else stored?.attended, - dailyPatrol = if (edit?.dailyPatrol != null) edit.dailyPatrol.value else stored?.dailyPatrol, + attended = attended, + dailyPatrol = dailyPatrol, ).takeIf { it.hasAnyValue() } } return Result.Valid(merged.sortedWith(compareBy({ it.gameDay }, { it.uid }))) 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 49298af..6930aa1 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyOverrideEditingTest.kt @@ -78,4 +78,68 @@ class WeeklyOverrideEditingTest { fun emptyEditsDoNotCreateOverrides() { assertTrue(valid(WeeklyOverrideEditing.merge(week, true, emptyList(), listOf(CellEdit(7, day)))).isEmpty()) } + + @Test + fun anEditedActivityChoiceResolvesAContradictionWithAStoredOne() { + // A completed Daily Patrol requires attendance, and storage rejects the contradiction. + assertEquals( + listOf(stored(attended = false, patrol = false)), + valid( + WeeklyOverrideEditing.merge( + week, + false, + listOf(stored(patrol = true)), + listOf(CellEdit(7, day, attended = Choice.NO)), + ), + ), + ) + assertEquals( + listOf(stored(attended = true, patrol = true)), + valid( + WeeklyOverrideEditing.merge( + week, + false, + listOf(stored(attended = false)), + listOf(CellEdit(7, day, dailyPatrol = Choice.YES)), + ), + ), + ) + } + + @Test + fun contradictoryActivityChoicesInOneEditAreInvalid() { + assertEquals( + Result.Invalid(7, day, Field.ACTIVITY), + WeeklyOverrideEditing.merge( + week, + false, + emptyList(), + listOf(CellEdit(7, day, attended = Choice.NO, dailyPatrol = Choice.YES)), + ), + ) + } + + @Test + fun everyValidMergeIsAcceptedByStorageRules() { + val choices = listOf(null) + Choice.entries + val storedValues = listOf(null, true, false) + storedValues.forEach { storedAttended -> + storedValues.forEach { storedPatrol -> + if (storedAttended == false && storedPatrol == true) return@forEach + choices.forEach { attended -> + choices.forEach { patrol -> + val result = WeeklyOverrideEditing.merge( + week, + false, + listOf(stored(merit = 1, attended = storedAttended, patrol = storedPatrol)), + listOf(CellEdit(7, day, attended = attended, dailyPatrol = patrol)), + ) + if (result is Result.Valid) { + assertTrue(result.overrides.none { it.attended == false && it.dailyPatrol == true }) + } + } + } + } + } + } } diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 564e647..63f6468 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -219,6 +219,11 @@ transitions. - Weekly history is a derived audit trail, not the primary store. If refreshing one revision fails after a successful data mutation, the affected restored selection is cleared so the live database projection remains authoritative. +- A manual Daily Patrol requires manual attendance. When an edit sets one of + the two and the other stored choice contradicts it, the edited choice wins + (Patrol yes also sets Login yes; Login no also sets Patrol no); Login no with + Patrol yes in the same edit is rejected before saving, so no edit can produce + a pair that storage refuses. - Weekly notes and manual overrides must fall inside their own week; anything else is rejected before it can make a revision fail its structural checks. - A captured roster older than the latest stored one, from a late packet or a From ced7d750ca0eb226eeafc85b489dd877bdcb94d2 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:32:54 +0900 Subject: [PATCH 117/125] fix: keep shared weekly labels and notes on whole characters The share projection cut member labels at 128 and private notes at 512 UTF-16 units, which could leave half of a surrogate pair and render a broken glyph. Shortening now stops before a split pair. --- .../app/management/WeeklyShareProjection.kt | 13 +++++++++--- .../management/WeeklyShareProjectionTest.kt | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt index 1fd447e..7705785 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyShareProjection.kt @@ -62,7 +62,7 @@ object WeeklyShareProjection { dailyCells = member.days.map { formatDaily(it, report.isGunsmokeWeek, labels) }, total = formatTotal(member, report.isGunsmokeWeek, labels), privateNote = privateNotesByUid[member.uid] - ?.take(MAX_PRIVATE_NOTE_CHARS) + ?.takeWhole(MAX_PRIVATE_NOTE_CHARS) ?.takeIf { privacy.includePrivateNotes && it.isNotBlank() }, ) } @@ -83,8 +83,8 @@ object WeeklyShareProjection { labels: Labels, ): String = when { privacy.includeMemberNames && privacy.includeUids -> - member.name.take(MAX_MEMBER_LABEL_CHARS) + " (#" + member.uid + ")" - privacy.includeMemberNames -> member.name.take(MAX_MEMBER_LABEL_CHARS) + member.name.takeWhole(MAX_MEMBER_LABEL_CHARS) + " (#" + member.uid + ")" + privacy.includeMemberNames -> member.name.takeWhole(MAX_MEMBER_LABEL_CHARS) privacy.includeUids -> "#" + member.uid else -> labels.anonymousMember(index + 1) } @@ -124,6 +124,13 @@ object WeeklyShareProjection { } private val DAY = DateTimeFormatter.ofPattern("MM/dd") + /** At most [maximum] UTF-16 units, never ending on the high half of a surrogate pair. */ + private fun String.takeWhole(maximum: Int): String { + if (length <= maximum) return this + val end = if (Character.isHighSurrogate(this[maximum - 1])) maximum - 1 else maximum + return substring(0, end) + } + private const val MAX_MEMBER_LABEL_CHARS = 128 private const val MAX_PRIVATE_NOTE_CHARS = 512 } diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt index 2d5431a..415e843 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyShareProjectionTest.kt @@ -78,6 +78,26 @@ class WeeklyShareProjectionTest { assertEquals("Member 1", document.rows.single().member) } + @Test + fun shortenedLabelsAndNotesNeverSplitASurrogatePair() { + val emoji = "\uD83D\uDE00" + // An even run of ASCII leaves the 128th and 512th UTF-16 unit on an emoji's high half. + val name = "a".repeat(127) + emoji + "tail" + val note = "b".repeat(511) + emoji + "tail" + val base = report() + val report = base.copy(members = base.members.map { it.copy(name = name) }) + + val row = WeeklyShareProjection.build( + report, + report.members, + privateNotesByUid = mapOf(123L to note), + privacy = WeeklyShareProjection.Privacy(includePrivateNotes = true), + ).rows.single() + + assertEquals("a".repeat(127), row.member) + assertEquals("b".repeat(511), row.privateNote) + } + private fun report(): WeeklyReportBuilder.Report { val start = LocalDate.of(2026, 8, 10) val days = (0L..6L).map(start::plusDays) From c83592d7093ae8b80e5209e8691e36416a6d74dd Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:34:50 +0900 Subject: [PATCH 118/125] fix: date undated membership boundaries in the game zone for overlap checks The consistency policy dated snapshot and exact boundaries in UTC while manual dates are game-zone calendar dates, and compared instants when a date was missing. A manual stay before a same-day exact join, or a date-only rejoin on the day of an exact withdrawal, was then rejected as an overlap. Missing dates are now derived in the Platoon's game zone. --- .../management/MembershipConsistencyPolicy.kt | 19 ++++-- .../app/management/MembershipEditing.kt | 27 +++++---- .../gf2log/app/management/PlatoonDatabase.kt | 6 +- .../MembershipConsistencyPolicyTest.kt | 59 ++++++++++++++++--- docs/PLATOON_MANAGEMENT.md | 5 +- 5 files changed, 89 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt index 74ac57d..80be714 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipConsistencyPolicy.kt @@ -2,7 +2,7 @@ package dev.gf2log.app.management import java.time.Instant import java.time.LocalDate -import java.time.ZoneOffset +import java.time.ZoneId /** One persisted membership interval used by the deterministic consistency audit. */ internal data class MembershipInterval( @@ -17,7 +17,18 @@ internal data class MembershipInterval( /** Enforces chronology rules shared by manual edits and post-mutation verification. */ internal object MembershipConsistencyPolicy { - fun violation(periods: List): String? { + /** + * Manual boundaries store the calendar date they were entered for in the game zone, while + * snapshot and exact boundaries store only an instant. Each missing date is derived in + * [zoneId], so both kinds are compared on the same calendar. + */ + fun violation(intervals: List, zoneId: ZoneId): String? { + val periods = intervals.map { period -> + period.copy( + joinedDate = period.joinedDate ?: period.joinedAt?.atZone(zoneId)?.toLocalDate(), + leftDate = period.leftDate ?: period.leftAt?.atZone(zoneId)?.toLocalDate(), + ) + } periods.forEach { period -> if (!hasValidOwnChronology(period)) { return "Membership period ${period.id} does not end after it starts" @@ -55,8 +66,8 @@ internal object MembershipConsistencyPolicy { private fun compareStarts(first: MembershipInterval, second: MembershipInterval): Int { val firstInstant = requireNotNull(first.joinedAt) val secondInstant = requireNotNull(second.joinedAt) - val firstDate = first.joinedDate ?: firstInstant.atZone(ZoneOffset.UTC).toLocalDate() - val secondDate = second.joinedDate ?: secondInstant.atZone(ZoneOffset.UTC).toLocalDate() + val firstDate = requireNotNull(first.joinedDate) + val secondDate = requireNotNull(second.joinedDate) val dateOrder = firstDate.compareTo(secondDate) if (dateOrder != 0) return dateOrder // Keep known instants contiguous and ordered before date-only boundaries. 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 e947f2d..691c362 100644 --- a/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt +++ b/app/src/main/java/dev/gf2log/app/management/MembershipEditing.kt @@ -14,6 +14,7 @@ import java.time.ZoneId internal object MembershipEditing { fun addWithdrawnMember( db: SQLiteDatabase, + gameZone: () -> ZoneId, uid: Long, name: String, joined: MembershipBoundaryValue, @@ -37,13 +38,14 @@ internal object MembershipEditing { ) val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, storedNote) replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, storedName, joined, withdrew) - synchronizeAndVerifyMembershipState(db, uid) + synchronizeAndVerifyMembershipState(db, gameZone(), uid) true } } fun addMembershipPeriod( db: SQLiteDatabase, + gameZone: () -> ZoneId, uid: Long, joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue?, @@ -53,12 +55,12 @@ internal object MembershipEditing { require(isValidMembershipRange(joined, withdrew)) return db.inTransaction { val memberName = memberName(db, uid) ?: return@inTransaction false - if (!canStoreMembershipPeriod(db, uid, null, joined, withdrew)) { + if (!canStoreMembershipPeriod(db, gameZone(), uid, null, joined, withdrew)) { return@inTransaction false } val membershipPeriodId = insertManualMembershipPeriod(db, uid, joined, withdrew, storedNote) replaceManualMembershipPeriodEvents(db, membershipPeriodId, uid, memberName, joined, withdrew) - synchronizeAndVerifyMembershipState(db, uid) + synchronizeAndVerifyMembershipState(db, gameZone(), uid) true } } @@ -102,7 +104,11 @@ internal object MembershipEditing { * 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. */ - fun deleteMembershipPeriod(db: SQLiteDatabase, membershipPeriodId: Long): Boolean { + fun deleteMembershipPeriod( + db: SQLiteDatabase, + gameZone: () -> ZoneId, + membershipPeriodId: Long, + ): Boolean { require(membershipPeriodId > 0) return db.inTransaction { val uid = db.rawQuery( @@ -120,7 +126,7 @@ internal object MembershipEditing { if (periodCount <= 1L) return@inTransaction false // A vanished row after its events were detached must roll those deletions back. if (!MembershipRows.deleteMembershipPeriod(db, membershipPeriodId)) return false - synchronizeAndVerifyMembershipState(db, uid) + synchronizeAndVerifyMembershipState(db, gameZone(), uid) true } } @@ -149,7 +155,7 @@ internal object MembershipEditing { left } require(isValidMembershipRange(effectiveJoined, effectiveLeft)) - if (!canStoreMembershipPeriod(db, period.uid, membershipPeriodId, effectiveJoined, effectiveLeft)) { + if (!canStoreMembershipPeriod(db, gameZone(), period.uid, membershipPeriodId, effectiveJoined, effectiveLeft)) { return@inTransaction false } val joinedChanged = editableMembershipBoundaryChanged( @@ -188,7 +194,7 @@ internal object MembershipEditing { effectiveLeft.takeIf { leftIsManual }, ) } - if (updated) synchronizeAndVerifyMembershipState(db, period.uid) + if (updated) synchronizeAndVerifyMembershipState(db, gameZone(), period.uid) updated } } @@ -199,6 +205,7 @@ internal object MembershipEditing { */ private fun canStoreMembershipPeriod( db: SQLiteDatabase, + zoneId: ZoneId, uid: Long, replacedPeriodId: Long?, joined: MembershipBoundaryValue, @@ -215,17 +222,17 @@ internal object MembershipEditing { ) val periods = readMembershipIntervals(db, uid) .filterNot { it.id == replacedPeriodId } + proposed - return MembershipConsistencyPolicy.violation(periods) == null + return MembershipConsistencyPolicy.violation(periods, zoneId) == null } /** * Verifies chronology, derives current activity from the canonical periods, and verifies * event ownership before the surrounding transaction may commit. */ - private fun synchronizeAndVerifyMembershipState(db: SQLiteDatabase, uid: Long) { + private fun synchronizeAndVerifyMembershipState(db: SQLiteDatabase, zoneId: ZoneId, uid: Long) { val periods = readMembershipIntervals(db, uid) checkNotNull(periods.takeIf { it.isNotEmpty() }) { "A member must retain a membership period" } - val violation = MembershipConsistencyPolicy.violation(periods) + val violation = MembershipConsistencyPolicy.violation(periods, zoneId) check(violation == null) { violation.orEmpty() } check(MembershipRows.synchronizeActiveState(db, listOf(uid)) == 1) { "Membership state has no owning member" 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 3e8e4eb..3afe367 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -123,7 +123,7 @@ internal class PlatoonDatabase private constructor( joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue, note: String, - ): Boolean = MembershipEditing.addWithdrawnMember(writableDatabase, uid, name, joined, withdrew, note) + ): Boolean = MembershipEditing.addWithdrawnMember(writableDatabase, ::gameZone, uid, name, joined, withdrew, note) @Synchronized fun addMembershipPeriod( @@ -131,7 +131,7 @@ internal class PlatoonDatabase private constructor( joined: MembershipBoundaryValue, withdrew: MembershipBoundaryValue?, note: String, - ): Boolean = MembershipEditing.addMembershipPeriod(writableDatabase, uid, joined, withdrew, note) + ): Boolean = MembershipEditing.addMembershipPeriod(writableDatabase, ::gameZone, uid, joined, withdrew, note) @Synchronized fun listSnapshots(limit: Int = 100): List { @@ -170,7 +170,7 @@ internal class PlatoonDatabase private constructor( @Synchronized fun deleteMembershipPeriod(membershipPeriodId: Long): Boolean = - MembershipEditing.deleteMembershipPeriod(writableDatabase, membershipPeriodId) + MembershipEditing.deleteMembershipPeriod(writableDatabase, ::gameZone, membershipPeriodId) @Synchronized fun updateMembershipPeriod( diff --git a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt index cd83ce6..b2fd434 100644 --- a/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/MembershipConsistencyPolicyTest.kt @@ -2,6 +2,8 @@ package dev.gf2log.app.management import java.time.Instant import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test @@ -14,13 +16,13 @@ class MembershipConsistencyPolicyTest { interval(2, "2026-02-01T00:00:00Z", null), ) - assertNull(MembershipConsistencyPolicy.violation(periods)) + assertNull(violation(periods)) } @Test fun overlappingAndMultipleOpenPeriodsAreRejected() { assertNotNull( - MembershipConsistencyPolicy.violation( + violation( listOf( interval(1, "2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"), interval(2, "2026-02-01T00:00:00Z", "2026-04-01T00:00:00Z"), @@ -28,7 +30,7 @@ class MembershipConsistencyPolicyTest { ), ) assertNotNull( - MembershipConsistencyPolicy.violation( + violation( listOf( interval(1, "2026-01-01T00:00:00Z", null), interval(2, "2026-02-01T00:00:00Z", null), @@ -41,7 +43,7 @@ class MembershipConsistencyPolicyTest { fun closedTimelineIsValid() { val periods = listOf(interval(1, "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z")) - assertNull(MembershipConsistencyPolicy.violation(periods)) + assertNull(violation(periods)) } @Test @@ -51,7 +53,7 @@ class MembershipConsistencyPolicyTest { interval(2, "2026-02-01T00:00:00Z", null), ) - assertNull(MembershipConsistencyPolicy.violation(periods)) + assertNull(violation(periods)) } @Test @@ -67,7 +69,7 @@ class MembershipConsistencyPolicyTest { leftTimeKnown = false, ) - assertNull(MembershipConsistencyPolicy.violation(listOf(period))) + assertNull(violation(listOf(period))) } @Test @@ -88,7 +90,7 @@ class MembershipConsistencyPolicyTest { joinedTimeKnown = false, ) - assertNull(MembershipConsistencyPolicy.violation(listOf(first, second))) + assertNull(violation(listOf(first, second))) } @Test @@ -109,7 +111,7 @@ class MembershipConsistencyPolicyTest { ) assertNull( - MembershipConsistencyPolicy.violation( + violation( listOf(laterInsertedFirst, earlierInsertedSecond), ), ) @@ -141,7 +143,7 @@ class MembershipConsistencyPolicyTest { leftDate = LocalDate.of(2026, 5, 4), ) - assertNull(MembershipConsistencyPolicy.violation(listOf(knownLate, dateOnly, knownEarly))) + assertNull(violation(listOf(knownLate, dateOnly, knownEarly))) } private fun interval(id: Long, joined: String?, left: String?) = MembershipInterval( @@ -149,4 +151,43 @@ class MembershipConsistencyPolicyTest { joinedAt = joined?.let(Instant::parse), leftAt = left?.let(Instant::parse), ) + + @Test + fun boundariesWithoutAStoredDateAreDatedInTheGameZone() { + val shanghai = ZoneId.of("Asia/Shanghai") + // A manual stay from 06:00 to 07:00 on 10-16, then an exact open join at 07:30 that day. + // In UTC the exact join falls on 10-15 and would sort, open, before the manual stay. + val manual = MembershipInterval( + id = 1, + joinedAt = Instant.parse("2026-10-15T22:00:00Z"), + leftAt = Instant.parse("2026-10-15T23:00:00Z"), + joinedDate = LocalDate.of(2026, 10, 16), + leftDate = LocalDate.of(2026, 10, 16), + ) + val exact = MembershipInterval(id = 2, joinedAt = Instant.parse("2026-10-15T23:30:00Z"), leftAt = null) + + assertNull(MembershipConsistencyPolicy.violation(listOf(manual, exact), shanghai)) + } + + @Test + fun aDateOnlyRejoinOnTheDayOfAnExactWithdrawalIsNotAProvenOverlap() { + val shanghai = ZoneId.of("Asia/Shanghai") + val withdrawn = MembershipInterval( + id = 1, + joinedAt = Instant.parse("2026-10-01T02:00:00Z"), + leftAt = Instant.parse("2026-10-16T02:00:00Z"), + ) + val rejoined = MembershipInterval( + id = 2, + joinedAt = Instant.parse("2026-10-15T16:00:00Z"), + leftAt = null, + joinedDate = LocalDate.of(2026, 10, 16), + joinedTimeKnown = false, + ) + + assertNull(MembershipConsistencyPolicy.violation(listOf(withdrawn, rejoined), shanghai)) + } + + private fun violation(periods: List) = + MembershipConsistencyPolicy.violation(periods, ZoneOffset.UTC) } diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 63f6468..5040b75 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -169,7 +169,10 @@ transitions. - Manual edits are accepted only when every period ends after it starts, no two fully dated periods overlap, and at most one period remains open. An absent snapshot-derived start remains unknown rather than being treated as an - infinitely old boundary. Every writer derives current active state from the + infinitely old boundary. Snapshot and exact boundaries are dated in the + Platoon's game timezone for this check, the same calendar manual dates use, + so a manual same-day boundary with an unknown time is never a proven overlap + with a timed one. Every writer derives current active state from the 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 From 295d1b01b62596f9becbf0d38bcc8c949129c117 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:36:38 +0900 Subject: [PATCH 119/125] fix: keep manual overrides in recorded weekly revisions The history codec never wrote a cell's manual override, so a restored revision lost its MANUAL precision, Clear action, and edited values, and an edit started from it saw no existing overrides. Revisions with overrides now use history format 3 and carry them; revisions without overrides keep format 2 bytes and fingerprints. --- .../management/WeeklyReportHistoryCodec.kt | 80 ++++++++++++++++++- .../WeeklyReportHistoryCodecTest.kt | 74 +++++++++++++++++ docs/PLATOON_MANAGEMENT.md | 4 +- 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt b/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt index 0a2bd39..09a22e9 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportHistoryCodec.kt @@ -27,10 +27,24 @@ internal object WeeklyReportHistoryCodec { require(revision.notes.size <= MAX_NOTES) require(revision.memberNamesByUid.size <= MAX_CONTEXT_MEMBERS) require(revision.memberPrivateNotesByUid.size <= MAX_MEMBERS) + val overrides = report.members.flatMap { member -> + member.days.mapIndexedNotNull { index, cell -> + cell.manualOverride?.let { override -> + require( + override.uid == member.uid && + override.periodStart == report.periodStart && + override.gameDay == cell.gameDay, + ) { "A manual override must belong to its cell" } + index to override + } + } + } + // A revision without overrides keeps its version-2 bytes, so its fingerprint is stable. + val formatVersion = if (overrides.isEmpty()) NOTES_FORMAT_VERSION else FORMAT_VERSION val raw = ByteArrayOutputStream().use { buffer -> DataOutputStream(buffer).use { output -> output.writeInt(MAGIC) - output.writeInt(FORMAT_VERSION) + output.writeInt(formatVersion) output.writeLong(report.periodStart.toEpochDay()) output.writeLong(report.periodEnd.toEpochDay()) output.writeBoolean(report.isGunsmokeWeek) @@ -44,6 +58,10 @@ internal object WeeklyReportHistoryCodec { revision.notes.forEach { output.writeWeeklyNote(it) } output.writeStringMap(revision.memberNamesByUid, MAX_STRING_BYTES) output.writeStringMap(revision.memberPrivateNotesByUid, MAX_PRIVATE_NOTE_BYTES) + if (formatVersion >= OVERRIDES_FORMAT_VERSION) { + output.writeInt(overrides.size) + overrides.forEach { (dayIndex, override) -> output.writeOverride(dayIndex, override) } + } } buffer.toByteArray() } @@ -101,13 +119,67 @@ internal object WeeklyReportHistoryCodec { val notes = List(noteCount) { input.readWeeklyNote(periodStart) } val names = input.readStringMap(MAX_CONTEXT_MEMBERS, MAX_STRING_BYTES) val privateNotes = input.readStringMap(MAX_MEMBERS, MAX_PRIVATE_NOTE_BYTES) - WeeklyTableRevision(report, events, notes, names, privateNotes) + val withOverrides = if (formatVersion >= OVERRIDES_FORMAT_VERSION) { + input.readOverrides(report) + } else { + report + } + WeeklyTableRevision(withOverrides, events, notes, names, privateNotes) } require(input.read() == -1) { "Trailing weekly history data" } ManagementConsistencyAudit.requireValid(revision) } } + private fun DataOutputStream.writeOverride(dayIndex: Int, override: WeeklyCellOverride) { + writeLong(override.uid) + writeInt(dayIndex) + writeNullableLong(override.meritDelta) + writeNullableLong(override.scoreDelta) + writeNullableInt(override.attempts) + writeNullableBoolean(override.attended) + writeNullableBoolean(override.dailyPatrol) + } + + /** Attaches each recorded manual override to its cell; every cell takes at most one. */ + private fun DataInputStream.readOverrides( + report: WeeklyReportBuilder.Report, + ): WeeklyReportBuilder.Report { + val count = readBoundedCount(MAX_MEMBERS * DAYS_PER_WEEK) + val rowsByUid = report.members.associateBy { it.uid } + val overrides = mutableMapOf, WeeklyCellOverride>() + repeat(count) { + val uid = readLong() + val dayIndex = readInt() + require(uid in rowsByUid && dayIndex in 0 until DAYS_PER_WEEK) { + "A manual override must belong to a recorded cell" + } + val override = WeeklyCellOverride( + uid = uid, + periodStart = report.periodStart, + gameDay = report.days[dayIndex], + meritDelta = readNullableLong(), + scoreDelta = readNullableLong(), + attempts = readNullableInt(), + attended = readNullableBoolean(), + dailyPatrol = readNullableBoolean(), + ) + require(overrides.put(uid to dayIndex, override) == null) { + "A weekly cell can have only one override" + } + } + if (overrides.isEmpty()) return report + return report.copy( + members = report.members.map { member -> + member.copy( + days = member.days.mapIndexed { index, cell -> + overrides[member.uid to index]?.let { cell.copy(manualOverride = it) } ?: cell + }, + ) + }, + ) + } + private fun DataOutputStream.writeMemberEvent(event: MemberEvent) { writeLong(event.id) writeLong(event.uid) @@ -403,7 +475,9 @@ internal object WeeklyReportHistoryCodec { private const val MAGIC = 0x47463248 private const val LEGACY_FORMAT_VERSION = 1 - private const val FORMAT_VERSION = 2 + private const val NOTES_FORMAT_VERSION = 2 + private const val OVERRIDES_FORMAT_VERSION = 3 + private const val FORMAT_VERSION = OVERRIDES_FORMAT_VERSION private const val DAYS_PER_WEEK = 7 private const val MAX_MEMBERS = 256 const val MAX_CONTEXT_MEMBERS = 768 diff --git a/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt b/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt index 12bddd5..4cabf05 100644 --- a/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt +++ b/app/src/test/java/dev/gf2log/app/management/WeeklyReportHistoryCodecTest.kt @@ -1,10 +1,14 @@ package dev.gf2log.app.management +import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.DataInputStream import java.time.Instant import java.time.LocalDate +import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -135,4 +139,74 @@ class WeeklyReportHistoryCodecTest { WeeklyReportHistoryCodec.decode(payload) } + + @Test + fun roundTripKeepsManualOverridesOnTheirCells() { + val start = LocalDate.of(2026, 7, 26) + val merit = WeeklyCellOverride(42L, start, start.plusDays(1), 90L, null, null, true, null) + val patrol = WeeklyCellOverride(42L, start, start.plusDays(3), null, null, null, null, false) + val report = standardReport(start) { day -> + when (day) { + merit.gameDay -> merit + patrol.gameDay -> patrol + else -> null + } + } + + val decoded = WeeklyReportHistoryCodec.decode(WeeklyReportHistoryCodec.encode(report).payload) + + val cells = decoded.members.single().days + assertEquals(report.members.single().days.map { it.manualOverride }, cells.map { it.manualOverride }) + assertEquals(EvidencePrecision.MANUAL, cells[1].precision) + assertEquals(report.members.single().days.map { it.observed }, cells.map { it.observed }) + } + + @Test + fun aRevisionWithoutOverridesKeepsItsEarlierFormatAndFingerprint() { + val start = LocalDate.of(2026, 7, 26) + val encoded = WeeklyReportHistoryCodec.encode(standardReport(start) { null }) + + val header = DataInputStream(GZIPInputStream(ByteArrayInputStream(encoded.payload))).use { + it.readInt() + it.readInt() + } + + assertEquals(2, header) + assertNull( + WeeklyReportHistoryCodec.decode(encoded.payload).members.single().days + .firstNotNullOfOrNull { it.manualOverride }, + ) + } + + private fun standardReport( + start: LocalDate, + overrideOn: (LocalDate) -> WeeklyCellOverride?, + ): WeeklyReportBuilder.Report { + val days = List(7) { start.plusDays(it.toLong()) } + return WeeklyReportBuilder.Report( + periodStart = start, + periodEnd = start.plusDays(6), + isGunsmokeWeek = false, + days = days, + members = listOf( + WeeklyReportBuilder.MemberRow( + uid = 42L, + name = "Edited member", + days = days.map { day -> + val override = overrideOn(day) + WeeklyReportBuilder.DayCell( + gameDay = day, + meritDelta = override?.meritDelta, + scoreDelta = null, + inference = null, + evidence = DailyEvidence.NO_OBSERVATION, + manualOverride = override, + ) + }, + totalMerit = 90L, + totalScore = 0L, + ), + ), + ) + } } diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 5040b75..9c2d628 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -204,7 +204,9 @@ transitions. - Each stored history item is a complete immutable weekly-table revision. The report cells, displayed membership events, weekly notes, names, and private notes are encoded together rather than combining an older table with current - member data. + member data. Each cell's manual override is encoded with it (history format + 3), so a restored revision still shows which cells were edited and by how + much; a revision without overrides keeps format 2 and its fingerprint. - Every revision passes deterministic structural checks before persistence and after decoding: seven contiguous days, unique member UIDs, matching cell dates and report mode, in-period notes, and valid contextual identities. From 9b04f8da267e296ddd1722ed9f1000331432f709 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:37:58 +0900 Subject: [PATCH 120/125] fix: read an unusable stored target package as the default Settings reads passed a target package copied unvalidated from the v2.2 store straight to the complete-backup codec, which rejected it, so complete export and restore failed. Reads now fall back to the default package exactly as the legacy migration already did. --- .../PlatoonBackupManagerIntegrationTest.kt | 4 +++- .../UserSettingsPreferencesIntegrationTest.kt | 14 ++++++++++++++ .../app/settings/UserSettingsPreferences.kt | 15 +++++++++------ 3 files changed, 26 insertions(+), 7 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 249b0ff..ce75b11 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -161,8 +161,10 @@ class PlatoonBackupManagerIntegrationTest { writeRetainedCsv(CURRENT_UID, "Cached current member") PlatoonBackupManager(context).restore(ByteArrayInputStream(archive)) + // The stored value is untouched; settings reads project an unusable one to the default. + assertEquals(INVALID_TARGET_PACKAGE, TargetPackagePreferences.get(context)) assertEquals( - currentSettings().copy(targetPackage = INVALID_TARGET_PACKAGE), + currentSettings().copy(targetPackage = TargetPackagePreferences.DEFAULT_TARGET_PACKAGE), settingsStore.read(), ) assertFalse(FilePaths.retainedCsvDirectory(context).exists()) diff --git a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt index d70ec9c..001c234 100644 --- a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt @@ -56,6 +56,20 @@ class UserSettingsPreferencesIntegrationTest { ) } + @Test + fun anUnusableStoredTargetPackageDoesNotBlockCompleteBackups() { + // A v2.2 unified store copied the target package without validating it. + context.getSharedPreferences("user_settings", Context.MODE_PRIVATE).edit() + .putInt("schema_version", 1) + .putString("target_package", "not a package") + .commit() + + val settings = UserSettingsPreferences.read(context) + + assertEquals(dev.gf2log.app.TargetPackagePreferences.DEFAULT_TARGET_PACKAGE, settings.targetPackage) + assertEquals(settings, AppBackupSettingsCodec.decode(AppBackupSettingsCodec.encode(settings))) + } + @Test fun cutlinesWrittenUnderTheExistingKeysRemainReadable() { context.getSharedPreferences("user_settings", Context.MODE_PRIVATE).edit() 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 117060e..bc02289 100644 --- a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt @@ -182,10 +182,9 @@ internal object UserSettingsPreferences { gameTimeZoneId = preferences.getString(KEY_GAME_TIME_ZONE, ZoneId.systemDefault().id).orEmpty(), onboardingCompleted = preferences.getBoolean(KEY_ONBOARDING_COMPLETED, false), detailedNotifications = preferences.getBoolean(KEY_DETAILED_NOTIFICATIONS, true), - targetPackage = preferences.getString( - KEY_TARGET_PACKAGE, - TargetPackagePreferences.DEFAULT_TARGET_PACKAGE, - ).orEmpty(), + // The target package is no longer chosen at runtime, so an unusable stored value (an + // unvalidated copy from the v2.2 store) falls back instead of failing every backup. + targetPackage = backupTargetPackage(preferences.getString(KEY_TARGET_PACKAGE, null)), payloadHistory = PayloadCatalog.categories.associate { category -> category.payloadType to (category.isRequired || preferences.getBoolean(payloadKey(category.payloadType), false)) @@ -218,8 +217,7 @@ internal object UserSettingsPreferences { Context.MODE_PRIVATE, ).safeString(TargetPackagePreferences.KEY_TARGET_PACKAGE) ?.trim() - ?.takeIf { TARGET_PACKAGE.matches(it) } - ?: TargetPackagePreferences.DEFAULT_TARGET_PACKAGE + .let(::backupTargetPackage) return AppBackupSettings( language = language, themeMode = "system", @@ -326,5 +324,10 @@ internal object UserSettingsPreferences { private fun payloadKey(payloadType: Int) = "payload_$payloadType" + /** [value] when the complete-backup codec accepts it, otherwise the default package. */ + private fun backupTargetPackage(value: String?): String = value + ?.takeIf { it.length in 3..255 && TARGET_PACKAGE.matches(it) } + ?: TargetPackagePreferences.DEFAULT_TARGET_PACKAGE + private val TARGET_PACKAGE = Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+") } From 50da2b8979819c04117aa40271f8e067b92f8677 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:39:28 +0900 Subject: [PATCH 121/125] fix: hold the CSV import claim through a failed seal's rollback seal released the scope claim in its own finally, so after a failed seal runImport rolled the import back unclaimed and then released the claim again, possibly clearing another operation's claim. runImport now holds one claim across apply, seal, and rollback and releases it once. --- .../PlatoonBackupManagerIntegrationTest.kt | 26 ++++++++++ .../management/CsvImportCheckpointManager.kt | 50 +++++++++++-------- 2 files changed, 56 insertions(+), 20 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 ce75b11..ecf1f04 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -572,6 +572,32 @@ class PlatoonBackupManagerIntegrationTest { assertTrue(retried.canUndo()) } + @Test + fun aFailedSealKeepsTheImportClaimUntilItsRollbackFinishes() { + seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") + var undoAvailableDuringRollback: Boolean? = null + lateinit var manager: CsvImportCheckpointManager + manager = CsvImportCheckpointManager(context, storageScope) { checkpoint -> + if (checkpoint == PlatoonBackupManager.RestoreCheckpoint.DATABASE_INSTALLED) { + undoAvailableDuringRollback = manager.canUndo() + } + } + + assertThrows(IllegalStateException::class.java) { + manager.runImport(emptySet()) { + // A digest that is already present makes seal fail after the import applied. + java.io.File(FilePaths.csvCheckpointDirectory(context), "post-import.sha256") + .writeText("0".repeat(64), Charsets.US_ASCII) + } + } + + // Another operation sees the claim for the whole rollback, then can take it again. + assertEquals(false, undoAvailableDuringRollback) + val retried = CsvImportCheckpointManager(context, storageScope) + retried.runImport(emptySet()) {} + assertTrue(retried.canUndo()) + } + @Test fun interruptedCsvUndoCompletesAfterDatabaseInstallationOnRestart() { seedDatabase(ARCHIVED_UID, "Before import", "before-import.csv") diff --git a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt index d0c483d..c621008 100644 --- a/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt +++ b/app/src/main/java/dev/gf2log/app/management/CsvImportCheckpointManager.kt @@ -100,39 +100,45 @@ class CsvImportCheckpointManager internal constructor( /** Seals a successful import so undo is refused after any later database mutation. */ fun seal() { try { - check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } - val target = digest(root) - check(!target.exists()) { "CSV import checkpoint is already sealed" } - val temporary = File(root, "$DIGEST_FILE.tmp") - try { - FileOutputStream(temporary).use { output -> - output.write( - backupManager().currentDatabaseSha256().toByteArray(Charsets.US_ASCII), - ) - output.fd.sync() - } - check(temporary.renameTo(target)) { "Unable to seal the CSV import checkpoint" } - } finally { - temporary.delete() - } - discard(previous) + sealClaimed() } finally { releaseOperation() } } + private fun sealClaimed() { + check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } + val target = digest(root) + check(!target.exists()) { "CSV import checkpoint is already sealed" } + val temporary = File(root, "$DIGEST_FILE.tmp") + try { + FileOutputStream(temporary).use { output -> + output.write( + backupManager().currentDatabaseSha256().toByteArray(Charsets.US_ASCII), + ) + output.fd.sync() + } + check(temporary.renameTo(target)) { "Unable to seal the CSV import checkpoint" } + } finally { + temporary.delete() + } + discard(previous) + } + /** * Runs one complete import: checkpoint, [apply], then seal. Any failure, including an * [Error], rolls the import back, and the scope's claim is always released. */ fun runImport(plannedFileNames: Set, apply: () -> T): T { create(plannedFileNames) + // The claim taken by create is held across apply, seal, and any rollback, and released + // exactly once, so no other operation on this scope can interleave with a rollback. try { val result = apply() - seal() + sealClaimed() return result } catch (failure: Throwable) { - runCatching(::rollbackFailedImport).exceptionOrNull()?.let(failure::addSuppressed) + runCatching(::rollbackClaimed).exceptionOrNull()?.let(failure::addSuppressed) throw failure } finally { releaseOperation() @@ -162,13 +168,17 @@ class CsvImportCheckpointManager internal constructor( internal fun rollbackFailedImport() { try { - check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } - restoreUnchecked() + rollbackClaimed() } finally { releaseOperation() } } + private fun rollbackClaimed() { + check(hasBaseCheckpoint()) { "No CSV import checkpoint is available" } + restoreUnchecked() + } + private fun restoreUnchecked() { val names = manifest(root).readLines(Charsets.UTF_8) .filter(String::isNotBlank) From 824fd7b30fbe4d4b1563d59633d1ef29f4b52785 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:40:43 +0900 Subject: [PATCH 122/125] refactor: remove unused management models and unreachable branches Drops the unreferenced PlatoonActivityFact and DailyActivity models, the unused MemberRow.observedDays, MemberRow.hasIncompleteEvidence, and DayCell.isGunsmokeActivityUnknown properties, a correlation sort branch excluded by its own WHERE clause, and an always-false solver condition, and reuses ActivityInference.counterDelta in snapshot comparison. --- .../management/ExactBoundaryCorrelation.kt | 6 +----- .../gf2log/app/management/PlatoonModels.kt | 19 ----------------- .../app/management/SnapshotComparison.kt | 9 +++----- .../app/management/StandardWeekSolver.kt | 3 +-- .../app/management/WeeklyReportBuilder.kt | 21 ------------------- 5 files changed, 5 insertions(+), 53 deletions(-) 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 b25b50d..c97e864 100644 --- a/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt +++ b/app/src/main/java/dev/gf2log/app/management/ExactBoundaryCorrelation.kt @@ -246,11 +246,7 @@ internal object ExactBoundaryCorrelation { AND source != ? AND ABS(COALESCE(occurred_at, observed_at) - ?) <= ? ORDER BY distance, - CASE source - WHEN '${EvidenceSource.GAME_UPDATES.name}' THEN 0 - WHEN '${EvidenceSource.MANUAL.name}' THEN 1 - ELSE 2 - END, + CASE source WHEN '${EvidenceSource.MANUAL.name}' THEN 0 ELSE 1 END, id DESC """.trimIndent(), arrayOf( 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 6107630..e245cc8 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonModels.kt @@ -77,17 +77,6 @@ data class MemberEvent( val note: String, ) -data class PlatoonActivityFact( - val id: Long, - val occurredAt: Instant, - val actionId: Long, - val kind: Long, - val memberName: String, - val capturedAt: Instant, - val resolvedUid: Long?, - val resolution: ActivityResolution, -) - data class PlatoonActivityObservation( val occurredAt: Instant, val actionId: Long, @@ -142,14 +131,6 @@ enum class EvidenceSource { LEGACY_IMPORT, } -data class DailyActivity( - val uid: Long, - val gameDay: LocalDate, - val meritDelta: Long, - val scoreDelta: Long, - val inference: ActivityInference.Result, -) - enum class DailyEvidence { NO_OBSERVATION, ATTRIBUTED, 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 a0d04ac..8087614 100644 --- a/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt +++ b/app/src/main/java/dev/gf2log/app/management/SnapshotComparison.kt @@ -32,9 +32,9 @@ object SnapshotComparison { MeritChange( uid = current.uid, name = current.name, - weeklyMeritDelta = counterDelta(current.weeklyMerit, previous.weeklyMerit), - totalMeritDelta = counterDelta(current.totalMerit, previous.totalMerit), - totalScoreDelta = counterDelta(current.totalScore, previous.totalScore), + weeklyMeritDelta = ActivityInference.counterDelta(previous.weeklyMerit, current.weeklyMerit), + totalMeritDelta = ActivityInference.counterDelta(previous.totalMerit, current.totalMerit), + totalScoreDelta = ActivityInference.counterDelta(previous.totalScore, current.totalScore), lastLoginChanged = current.lastLogin != previous.lastLogin, ) }.filter { @@ -45,7 +45,4 @@ object SnapshotComparison { }.sortedByDescending(MeritChange::weeklyMeritDelta), ) } - - private fun counterDelta(current: Long, previous: Long): Long = - if (current >= previous) current - previous else current } 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 32ed303..93622d5 100644 --- a/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt +++ b/app/src/main/java/dev/gf2log/app/management/StandardWeekSolver.kt @@ -294,8 +294,7 @@ object StandardWeekSolver { observationIndex >= 0 && observationIndex <= index } .all { observation -> - val observationIndex = days.indexOf(observation.gameDay) - observationIndex > index || checkpointStage( + checkpointStage( candidate, indexes.take(candidate.size), days, 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 3cc040f..dfd7859 100644 --- a/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt +++ b/app/src/main/java/dev/gf2log/app/management/WeeklyReportBuilder.kt @@ -36,9 +36,6 @@ object WeeklyReportBuilder { val resolvedStandardTotals: ResolvedStandardTotals? = null, val gunsmokeAttemptsFloor: Int? = null, ) { - val observedDays: List - get() = days.filter { it.observed } - val totalAttempts: Int? get() { val known = days.mapNotNull(DayCell::attempts) @@ -144,14 +141,6 @@ object WeeklyReportBuilder { loginDaysCertainty, patrolDaysCertainty, ).any { certainty -> certainty != MetricCertainty.EXACT } - - val hasIncompleteEvidence: Boolean - get() = days.any { - it.evidence == DailyEvidence.NO_OBSERVATION || - it.evidence == DailyEvidence.INCOMPLETE_BOUNDARY || - it.evidence == DailyEvidence.PARTIAL_DAY || - it.evidence == DailyEvidence.SPARSE_INFERRED - } } data class ResolvedGunsmokeTotals( @@ -285,16 +274,6 @@ object WeeklyReportBuilder { evidence == DailyEvidence.ATTRIBUTED || evidence == DailyEvidence.PARTIAL_DAY || evidence == DailyEvidence.SPARSE_INFERRED - - val isGunsmokeActivityUnknown: Boolean - get() = manualOverride == null && - !hasDailyPatrolFact && - evidence in setOf( - DailyEvidence.NO_OBSERVATION, - DailyEvidence.INCOMPLETE_BOUNDARY, - DailyEvidence.PARTIAL_DAY, - DailyEvidence.SPARSE_INFERRED, - ) } fun build( From b3d65d4c5da7f9fb2e507723f5931a623d93b37b Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:42:10 +0900 Subject: [PATCH 123/125] fix: refresh the weeks of roster boundaries correlated to earlier activity A roster that joins or withdraws a member can date the boundary to an activity time after the previous roster, in an earlier week, but only the roster's own week was re-recorded. The refresh now also covers the times of the membership events the roster recorded. --- .../PlatoonRepositoryIntegrationTest.kt | 35 +++++++++++++++++++ .../gf2log/app/management/PlatoonDatabase.kt | 4 +++ .../app/management/PlatoonReadQueries.kt | 11 ++++++ .../app/management/PlatoonRepository.kt | 17 ++++++++- docs/PLATOON_MANAGEMENT.md | 5 ++- 5 files changed, 70 insertions(+), 2 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 ffff95a..793b081 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonRepositoryIntegrationTest.kt @@ -6,6 +6,8 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import dev.gf2log.app.settings.GameTimeZonePreferences import dev.gf2log.protocol.model.GuildMember +import dev.gf2log.protocol.model.PlatoonActivityData +import dev.gf2log.protocol.model.PlatoonActivityEntry import java.io.File import java.time.Instant import java.time.LocalDate @@ -100,6 +102,39 @@ class PlatoonRepositoryIntegrationTest { assertEquals(liveReport(repository, OPENING_WEEK), after) } + @Test + fun aRosterJoinCorrelatedIntoAnEarlierWeekRefreshesThatWeek() { + val repository = utcRepository() + repository.ingest(Instant.parse("2026-07-29T12:00:00Z"), listOf(member(UID, "A")), "a.csv") + // B's first activity on Saturday evening is the only candidate for B's join time. + repository.ingestActivity( + PlatoonActivityData( + summaries = emptyList(), + entries = listOf( + PlatoonActivityEntry( + kind = 1u, + occurredAt = Instant.parse("2026-08-01T23:00:00Z").epochSecond.toUInt(), + actionId = 1u, + memberName = "B", + ), + ), + ), + capturedAt = Instant.parse("2026-08-01T23:05:00Z"), + ) + assertEquals(listOf(UID), latestStoredRevision(repository, CLOSING_WEEK)?.members?.map { it.uid }) + + // Monday's roster shows B; the exact join it correlates lies in the closing week. + repository.ingest( + Instant.parse("2026-08-03T12:00:00Z"), + listOf(member(UID, "A"), member(OTHER_UID, "B")), + "b.csv", + ) + + val after = latestStoredRevision(repository, CLOSING_WEEK) + assertEquals(setOf(UID, OTHER_UID), after?.members?.map { it.uid }?.toSet()) + assertEquals(liveReport(repository, CLOSING_WEEK), after) + } + @Test fun manualTextBeyondItsLimitIsRejectedWithoutChangingStoredRows() { val repository = utcRepository() 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 3afe367..d15f281 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonDatabase.kt @@ -97,6 +97,10 @@ internal class PlatoonDatabase private constructor( fun firstSnapshotCapturedAfter(instant: Instant): Instant? = PlatoonReadQueries.firstSnapshotCapturedAfter(readableDatabase, instant) + @Synchronized + fun eventInstantsObservedAt(observedAt: Instant): List = + PlatoonReadQueries.eventInstantsObservedAt(readableDatabase, observedAt) + @Synchronized fun snapshotSourceFiles(): Set = PlatoonReadQueries.snapshotSourceFiles(readableDatabase) 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 1ccb9b3..164327d 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonReadQueries.kt @@ -29,6 +29,17 @@ internal object PlatoonReadQueries { if (cursor.moveToFirst() && !cursor.isNull(0)) Instant.ofEpochMilli(cursor.getLong(0)) else null } + /** When the timed membership events observed at [observedAt] happened, newest last. */ + fun eventInstantsObservedAt(db: SQLiteDatabase, observedAt: Instant): List = db.rawQuery( + "SELECT DISTINCT COALESCE(occurred_at, observed_at) FROM member_events " + + "WHERE observed_at = ? AND event_date IS NULL ORDER BY 1", + arrayOf(observedAt.toEpochMilli().toString()), + ).use { cursor -> + buildList { + while (cursor.moveToNext()) add(Instant.ofEpochMilli(cursor.getLong(0))) + } + } + fun snapshotSourceFiles(db: SQLiteDatabase): Set = db.rawQuery( "SELECT source_file FROM snapshots WHERE source_file IS NOT NULL", null, 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 dfa5be2..108258e 100644 --- a/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt +++ b/app/src/main/java/dev/gf2log/app/management/PlatoonRepository.kt @@ -52,11 +52,26 @@ internal class PlatoonRepository( result.duplicate -> Unit // A replayed older roster can rebuild membership periods across every week. result.historical -> recordAllLiveWeeklyReports() - else -> recordChangedWeeks(setOf(capturedAt)) + else -> recordChangedWeeks(sequenceOf(capturedAt) + rosterBoundaryInstants(result, capturedAt)) } return result } + /** + * A roster that joins or withdraws members can correlate each boundary to an exact activity + * time anywhere after the previous roster, possibly in an earlier week. Those events are + * observed at [capturedAt], so their times name every week the roster changed. + */ + private fun rosterBoundaryInstants(result: SnapshotIngestResult, capturedAt: Instant): List { + if (result.joined + result.rejoined + result.left == 0) return emptyList() + return try { + access { it.eventInstantsObservedAt(capturedAt) } + } catch (error: Exception) { + Log.e(TAG, "Unable to read roster boundary times; refreshing the roster's week", error) + emptyList() + } + } + fun ingestActivity( data: PlatoonActivityData, capturedAt: Instant = Instant.now(), diff --git a/docs/PLATOON_MANAGEMENT.md b/docs/PLATOON_MANAGEMENT.md index 9c2d628..c2d8d80 100644 --- a/docs/PLATOON_MANAGEMENT.md +++ b/docs/PLATOON_MANAGEMENT.md @@ -220,7 +220,10 @@ transitions. the next stored roster, so those weeks are re-recorded as well; this covers a roster captured just before the reset. An instant before 05:00 on a calendar Sunday also refreshes the following week, whose Join / Withdraw section uses - calendar days. The 32-period per-ingest bound still applies, own weeks first. + calendar days. A roster that joins or withdraws members also refreshes the + weeks of the exact boundary times it correlates from earlier activity, which + can precede the roster's own week. The 32-period per-ingest bound still + applies, own weeks first. - Weekly history is a derived audit trail, not the primary store. If refreshing one revision fails after a successful data mutation, the affected restored selection is cleared so the live database projection remains authoritative. From f9e81ae304ad6ee6b39351736794ef901fb1a771 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 02:45:26 +0900 Subject: [PATCH 124/125] fix: save weekly edits against stored overrides and bound manual text fields - Weekly saves merge with the stored overrides, so a restored table or an off-roster member keeps its values - Nickname and note inputs use the persistence limits; contradictory login/patrol choices get their own message - Follow the weekly PNG state into its export flow in the recreation test --- .../PlatoonBackupManagerIntegrationTest.kt | 8 +++++--- .../java/dev/gf2log/app/MemberDetailActivity.kt | 9 +++++---- app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt | 6 +++++- .../java/dev/gf2log/app/WeeklyJournalSection.kt | 4 ++-- .../java/dev/gf2log/app/WeeklyReportActivity.kt | 14 +++++++++++++- .../java/dev/gf2log/app/WithdrawnMemberDialog.kt | 5 +++-- app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 8 files changed, 35 insertions(+), 13 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 ecf1f04..6551579 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -1260,15 +1260,17 @@ class WeeklyReportActivityStateTest { val target = WeeklyPngExport .newRenderTarget(context.cacheDir, LocalDate.of(2026, 8, 9)) .apply { writeBytes(byteArrayOf(1, 2, 3)) } - val field = WeeklyReportActivity::class.java.getDeclaredField("pendingPng").apply { + // The pending render lives in the screen's export flow. + val exports = WeeklyReportActivity::class.java.getDeclaredField("exports").apply { isAccessible = true } + val pending = dev.gf2log.app.WeeklyExportFlow::class.java.getDeclaredField("pendingPng").apply { isAccessible = true } try { ActivityScenario.launch(WeeklyReportActivity::class.java).use { scenario -> - scenario.onActivity { activity -> field.set(activity, target) } + scenario.onActivity { activity -> pending.set(exports.get(activity), target) } scenario.recreate() scenario.onActivity { activity -> - assertEquals(target.canonicalFile, (field.get(activity) as java.io.File).canonicalFile) + assertEquals(target.canonicalFile, (pending.get(exports.get(activity)) as java.io.File).canonicalFile) } } } finally { diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 0288e46..44b212e 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -1,5 +1,6 @@ package dev.gf2log.app +import dev.gf2log.app.management.ManualTextPolicy import android.app.AlertDialog import android.os.Bundle import android.view.Gravity @@ -103,8 +104,8 @@ class MemberDetailActivity : BaseActivity() { } private fun buildContent() { - nickname = textField(getString(R.string.member_nickname), id = R.id.member_nickname_input, maxLength = 256) - note = textField(getString(R.string.member_private_note_hint), id = R.id.member_note_input, singleLine = false) + nickname = textField(getString(R.string.member_nickname), id = R.id.member_nickname_input, maxLength = ManualTextPolicy.MAX_MEMBER_NAME_CHARS) + note = textField(getString(R.string.member_private_note_hint), id = R.id.member_note_input, singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS) saveButton = button(getString(R.string.save_member), Controls.Role.PRIMARY) { saveProfile() } nickname.onTextChanged { updateSaveState() } note.onTextChanged { updateSaveState() } @@ -292,7 +293,7 @@ class MemberDetailActivity : BaseActivity() { editable = period.isLeftBoundaryEditable, zone = zone, ) - val noteInput = textField(getString(R.string.membership_note_hint), singleLine = false).apply { setText(period.note) } + val noteInput = textField(getString(R.string.membership_note_hint), 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) { @@ -363,7 +364,7 @@ class MemberDetailActivity : BaseActivity() { 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) + val noteInput = textField(getString(R.string.membership_note_hint), singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS) val error = errorLine() val body = Dialogs.content(this) { addView(joined, lp()) diff --git a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt index d8c80c8..89d7e96 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyDialogs.kt @@ -139,7 +139,11 @@ internal object WeeklyDialogs { ) if (probe is WeeklyOverrideEditing.Result.Invalid) { error.showMessage(activity.getString( - if (probe.field == WeeklyOverrideEditing.Field.ATTEMPTS) R.string.invalid_attempts else R.string.invalid_counter, + when (probe.field) { + WeeklyOverrideEditing.Field.ATTEMPTS -> R.string.invalid_attempts + WeeklyOverrideEditing.Field.ACTIVITY -> R.string.invalid_activity_choice + else -> R.string.invalid_counter + }, )) return } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt b/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt index a4c6a88..a2afeb2 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyJournalSection.kt @@ -1,5 +1,6 @@ package dev.gf2log.app +import dev.gf2log.app.management.ManualTextPolicy import android.app.Activity import android.content.res.ColorStateList import android.os.Bundle @@ -72,7 +73,7 @@ internal class WeeklyJournalSection( activity.getString(R.string.note_hint), id = R.id.weekly_note_input, singleLine = false, - maxLength = MAX_NOTE_LENGTH, + maxLength = ManualTextPolicy.MAX_NOTE_CHARS, ) private val noteDayButton: TextView = activity.text(null, TextRole.BODY) { gravity = Gravity.CENTER_VERTICAL @@ -267,7 +268,6 @@ internal class WeeklyJournalSection( } private companion object { - const val MAX_NOTE_LENGTH = 1_000 const val STATE_NOTE_DAY = "weekly.note_day" const val STATE_NOTE_DRAFT = "weekly.note_draft" } diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 36ff753..8a601bf 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -14,6 +14,7 @@ import androidx.recyclerview.widget.RecyclerView import dev.gf2log.app.management.MemberEvent import dev.gf2log.app.management.PlatoonPeriods import dev.gf2log.app.management.PlatoonRepository +import dev.gf2log.app.management.WeeklyCellOverride import dev.gf2log.app.management.PlatoonStorageScope import dev.gf2log.app.management.WeeklyEvidenceAnalyzer import dev.gf2log.app.management.WeeklyMemberNameProjection @@ -523,8 +524,19 @@ class WeeklyReportActivity : BaseActivity() { private fun saveEdits() { val current = model ?: return + if (work.isBusy(SAVE)) return + val report = current.report + // Merge against the stored overrides, not the displayed table: a restored revision or a member + // missing from the roster would otherwise drop overrides the user never touched. + work.run(key = SAVE, task = { repository.listWeeklyOverrides(report.periodStart.toEpochDay()) }) { stored -> + stored.onFailure { Feedback.show(this, R.string.save_failed) } + stored.onSuccess { saveMerged(current, it) } + } + updateTopBar() + } + + private fun saveMerged(current: RenderModel, existing: List) { val report = current.report - val existing = report.members.flatMap { member -> member.days.mapNotNull { it.manualOverride } } when (val merged = WeeklyOverrideEditing.merge(report.periodStart, report.isGunsmokeWeek, existing, edits.values)) { is WeeklyOverrideEditing.Result.Invalid -> { val name = current.namesByUid[merged.uid] ?: Labels.uid(this, merged.uid) diff --git a/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt b/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt index 8b5339d..4f23d9b 100644 --- a/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt +++ b/app/src/main/java/dev/gf2log/app/WithdrawnMemberDialog.kt @@ -1,5 +1,6 @@ package dev.gf2log.app +import dev.gf2log.app.management.ManualTextPolicy import android.app.Activity import android.app.AlertDialog import android.text.InputType @@ -26,13 +27,13 @@ internal object WithdrawnMemberDialog { ) = 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))) + 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 note = LabeledField( this, getString(R.string.membership_note_hint), - textField(getString(R.string.membership_note_hint), singleLine = false), + textField(getString(R.string.membership_note_hint), singleLine = false, maxLength = ManualTextPolicy.MAX_NOTE_CHARS), ) val error = errorLine() val content = Dialogs.content(this) { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c515663..86ba3da 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -597,4 +597,5 @@ 적용 후 다음 날 캡처 중입니다 캡처 중지 후 계속 + 출석이 아니요이면 과업을 예로 둘 수 없습니다. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d54d6b6..d052e27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -605,4 +605,5 @@ Apply, next day Capture is running Stop capture + Patrol can\'t be yes while login is no. From 0aa99778f0d7453a5974b05a817935c4b29450dc Mon Sep 17 00:00:00 2001 From: Alex Han Date: Fri, 16 Oct 2026 07:51:57 +0900 Subject: [PATCH 125/125] docs: changelog for the final 2.5.0 fixes --- CHANGELOG.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7577a8..672bfe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to mobileGF2logger are documented here. -## 2.5.0 - 2026-10-15 +## 2.5.0 - 2026-10-16 ### Added @@ -90,6 +90,17 @@ All notable changes to mobileGF2logger are documented here. - Report admission failures inside the New Platoon dialog, block repeated send/share/backup/restore taps, ask before leaving an edited member, and dismiss a screen's dialogs when it closes. +- Offer Stop capture in place when a backup, restore, CSV import or profile + change needs capture stopped, and preselect the configured server when a new + Platoon is detected. +- Refresh neighbouring weekly tables that read a capture at the 05:00 reset, + keep restored reset-zone overrides consistent with the Platoon's server, and + roll interrupted restores back to the exact previous settings. +- Keep manual weekly values in restored tables and when saving edits, settle + contradictory Login/Patrol choices, and date manual membership overlaps in + the game time zone. +- Bound manual member names to 256 and notes to 1,000 characters, and close + date pickers and dialogs that belong to a finishing or recreated screen. ## 2.4.2 - 2026-08-27