diff --git a/apps/android/app/src/main/java/chat/mural/MuralViewModel.kt b/apps/android/app/src/main/java/chat/mural/MuralViewModel.kt index b9ce107..8c9d6f6 100644 --- a/apps/android/app/src/main/java/chat/mural/MuralViewModel.kt +++ b/apps/android/app/src/main/java/chat/mural/MuralViewModel.kt @@ -971,7 +971,7 @@ class MuralViewModel(application: Application) : AndroidViewModel(application) { } fun sendTyped(text: String) { typedReplyError = null - val clean = text.trim().take(2000) + val clean = TextLimits.clampTypedReply(text.trim()) if (clean.isEmpty() || working || state in listOf("connecting", "closing") || !cloudReady()) return if (state != "active") { if (conversationProvider == ConversationProvider.HOSTED_MINUTES) { @@ -1080,7 +1080,7 @@ class MuralViewModel(application: Application) : AndroidViewModel(application) { val record = archive.sessions.firstOrNull { it.id == sessionID }?.let(::clone) ?: return val passage = record.passages.firstOrNull { it.id == passageID && it.speaker == Speaker.user } ?: return finalAssessments.cancel(sessionID); hostedFinalAssessmentJobs.remove(sessionID)?.cancel(); generation++; meanings.reset() - passage.fragments.forEachIndexed { index, f -> record.correctFragment(f.id, if (index == 0) text.take(10000) else "") } + passage.fragments.forEachIndexed { index, f -> record.correctFragment(f.id, if (index == 0) TextLimits.clampCorrection(text) else "") } save(record); if (session?.id == record.id) session = record } fun deleteLearningData() { diff --git a/apps/android/app/src/main/java/chat/mural/core/TextLimits.kt b/apps/android/app/src/main/java/chat/mural/core/TextLimits.kt new file mode 100644 index 0000000..197cae1 --- /dev/null +++ b/apps/android/app/src/main/java/chat/mural/core/TextLimits.kt @@ -0,0 +1,13 @@ +package chat.mural.core + +/** Shared caps for learner-authored text. Both clients must refuse or stop accepting + * input past these lengths instead of silently truncating on save/send. */ +object TextLimits { + const val TYPED_REPLY_CHARACTERS = 2_000 + const val CORRECTION_CHARACTERS = 10_000 + + fun clampTypedReply(text: String): String = text.take(TYPED_REPLY_CHARACTERS) + fun clampCorrection(text: String): String = text.take(CORRECTION_CHARACTERS) + fun typedReplyExceedsLimit(text: String): Boolean = text.length > TYPED_REPLY_CHARACTERS + fun correctionExceedsLimit(text: String): Boolean = text.length > CORRECTION_CHARACTERS +} diff --git a/apps/android/app/src/main/java/chat/mural/ui/SettingsScreen.kt b/apps/android/app/src/main/java/chat/mural/ui/SettingsScreen.kt index 89cae4f..d300f85 100644 --- a/apps/android/app/src/main/java/chat/mural/ui/SettingsScreen.kt +++ b/apps/android/app/src/main/java/chat/mural/ui/SettingsScreen.kt @@ -64,6 +64,7 @@ import chat.mural.core.MeaningLanguages import chat.mural.core.Passage import chat.mural.core.SessionRecord import chat.mural.core.Speaker +import chat.mural.core.TextLimits import chat.mural.core.UsageSummary @Composable @@ -378,8 +379,18 @@ private fun CorrectionDialog(passage: Passage, onSave: (String) -> Unit, onDismi AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.history_correction_dialog_title)) }, - text = { MuralTextField(text, { text = it.take(10_000) }, minLines = 3, maxLines = 9) }, - confirmButton = { Button(onClick = { onSave(text.trim()) }, enabled = text.isNotBlank()) { Text(stringResource(R.string.common_save)) } }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + MuralTextField(text, { text = TextLimits.clampCorrection(it) }, minLines = 3, maxLines = 9) + Text("${text.length}/${TextLimits.CORRECTION_CHARACTERS}", color = MuralColors.Secondary, style = MaterialTheme.typography.bodySmall) + } + }, + confirmButton = { + Button( + onClick = { onSave(text.trim()) }, + enabled = text.isNotBlank() && !TextLimits.correctionExceedsLimit(text), + ) { Text(stringResource(R.string.common_save)) } + }, dismissButton = { MuralTextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } }, ) } diff --git a/apps/android/app/src/main/java/chat/mural/ui/TalkScreen.kt b/apps/android/app/src/main/java/chat/mural/ui/TalkScreen.kt index ecc513b..914113b 100644 --- a/apps/android/app/src/main/java/chat/mural/ui/TalkScreen.kt +++ b/apps/android/app/src/main/java/chat/mural/ui/TalkScreen.kt @@ -76,6 +76,7 @@ import chat.mural.MuralViewModel import chat.mural.R import chat.mural.core.SessionRecord import chat.mural.core.Speaker +import chat.mural.core.TextLimits @Composable fun TalkScreen( @@ -340,13 +341,14 @@ internal fun TypedReplySheet(languageName: String, working: Boolean, onSend: (St } Text(stringResource(R.string.talk_typed_reply_subtitle, languageName), color = MuralColors.Secondary, style = MaterialTheme.typography.bodyMedium) - MuralTextField(text, { text = it.take(2_000); onTyping() }, modifier = Modifier.fillMaxWidth().testTag("typed-reply-input").focusRequester(focus).onGloballyPositioned { + MuralTextField(text, { text = TextLimits.clampTypedReply(it); onTyping() }, modifier = Modifier.fillMaxWidth().testTag("typed-reply-input").focusRequester(focus).onGloballyPositioned { if (!requestedFocus) { requestedFocus = true; focus.requestFocus() } }, minLines = 3, maxLines = 6, label = { Text(stringResource(R.string.talk_typed_reply_field_label)) }) + Text("${text.length}/${TextLimits.TYPED_REPLY_CHARACTERS}", color = MuralColors.Secondary, style = MaterialTheme.typography.bodySmall) if (error != null) Text(error, color = MuralColors.Secondary, style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("typed-reply-error")) - Button(onClick = { onSend(text.trim()) }, enabled = text.isNotBlank() && !working, + Button(onClick = { onSend(text.trim()) }, enabled = text.isNotBlank() && !working && !TextLimits.typedReplyExceedsLimit(text), modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).bringIntoViewRequester(sendIntoView).testTag("typed-reply-send"), shape = CircleShape) { Text(stringResource(R.string.talk_typed_reply_send_button)); Spacer(Modifier.width(8.dp)) MuralIcon(MuralSymbol.ArrowUp, Modifier.size(18.dp)) diff --git a/apps/android/app/src/test/java/chat/mural/core/CoreTest.kt b/apps/android/app/src/test/java/chat/mural/core/CoreTest.kt index 6f2187b..7954fb2 100644 --- a/apps/android/app/src/test/java/chat/mural/core/CoreTest.kt +++ b/apps/android/app/src/test/java/chat/mural/core/CoreTest.kt @@ -5,6 +5,16 @@ import org.junit.Test import kotlinx.serialization.json.jsonObject class CoreTest { + @Test fun textLimitsMatchSharedCapsAndRefuseSilentOverflow() { + assertEquals(2_000, TextLimits.TYPED_REPLY_CHARACTERS) + assertEquals(10_000, TextLimits.CORRECTION_CHARACTERS) + val reply = "a".repeat(2_001) + assertTrue(TextLimits.typedReplyExceedsLimit(reply)) + assertEquals(2_000, TextLimits.clampTypedReply(reply).length) + val correction = "b".repeat(10_001) + assertTrue(TextLimits.correctionExceedsLimit(correction)) + assertEquals(10_000, TextLimits.clampCorrection(correction).length) + } private fun evidence(language:String="nb", day:Double=0.0, kind:EvidenceKind=EvidenceKind.independent, supported:Boolean=false, theme:String="walk"):SessionRecord { val date=1_780_000_000.0 + day*86400 val s=SessionRecord(languageID=language,startedAt=date,themeID=theme) diff --git a/apps/ios/App/ConversationCoordinator.swift b/apps/ios/App/ConversationCoordinator.swift index bf2cf9e..9d6e2dc 100644 --- a/apps/ios/App/ConversationCoordinator.swift +++ b/apps/ios/App/ConversationCoordinator.swift @@ -500,7 +500,8 @@ import MuralCore } let sessionID = draft.id let offset = Int(Date().timeIntervalSince(draft.startedAt) * 1000) - let fragment = Fragment(speaker: .user, text: String(clean.prefix(2000)), startMS: offset, endMS: offset + 1, + let clamped = TextLimits.clampTypedReply(clean) + let fragment = Fragment(speaker: .user, text: clamped, startMS: offset, endMS: offset + 1, meaningVisible: store.preferences.meaningVisible, typed: true) draft.append(fragment) activity.learnerEngaged(now: activityNow); inactivitySeconds = nil @@ -513,7 +514,7 @@ import MuralCore return false } addUsage(result.usage) - guard append("thinking", "The learner typed (data): \(String(clean.prefix(650)))"), + guard append("thinking", "The learner typed (data): \(String(clamped.prefix(650)))"), append("commentary", result.text) else { typedReplyError = "Your reply couldn’t be sent. Check your connection and try again." return false diff --git a/apps/ios/App/LibraryViews.swift b/apps/ios/App/LibraryViews.swift index c6dddb9..b58c57b 100644 --- a/apps/ios/App/LibraryViews.swift +++ b/apps/ios/App/LibraryViews.swift @@ -271,13 +271,24 @@ struct EditableTranscriptView: View { }.sheet(isPresented: Binding(get: { editingID != nil }, set: { if !$0 { editingID = nil } })) { NavigationStack { VStack(alignment: .leading, spacing: 20) { - TextField("What you said", text: $editedText, axis: .vertical).lineLimit(4...10).padding(18).background(.white, in: RoundedRectangle(cornerRadius: 20)) + TextField("What you said", text: Binding( + get: { editedText }, + set: { editedText = TextLimits.clampCorrection($0) } + ), axis: .vertical).lineLimit(4...10).padding(18).background(.white, in: RoundedRectangle(cornerRadius: 20)) + Text("\(editedText.count)/\(TextLimits.correctionCharacters)") + .font(.footnote).foregroundStyle(MuralColor.secondary) Text("Correct a misheard phrase. Learning evidence from the old wording will be removed; the original remains in your backup history.").font(.footnote).foregroundStyle(MuralColor.secondary) Spacer() }.padding(24).background(MuralColor.cream).navigationTitle("What you said").navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { editingID = nil } } - ToolbarItem(placement: .confirmationAction) { Button("Save") { if let id = editingID { store.correctPassage(sessionID: sessionID, passageID: id, text: editedText) }; editingID = nil } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + guard !TextLimits.correctionExceedsLimit(editedText) else { return } + if let id = editingID { store.correctPassage(sessionID: sessionID, passageID: id, text: editedText) } + editingID = nil + }.disabled(editedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } } }.presentationDetents([.medium, .large]) } diff --git a/apps/ios/App/RootView.swift b/apps/ios/App/RootView.swift index dcaec55..bd3b697 100644 --- a/apps/ios/App/RootView.swift +++ b/apps/ios/App/RootView.swift @@ -242,20 +242,31 @@ struct TypedReplyView: View { @State private var sending = false @Environment(\.dismiss) private var dismiss @FocusState private var focused: Bool + private var canSend: Bool { + !sending && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !TextLimits.typedReplyExceedsLimit(text) + } var body: some View { NavigationStack { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 20) { Text("Say it your way.").font(.system(.title, design: .rounded, weight: .semibold)).fixedSize(horizontal: false, vertical: true) - TextField("Reply in \(coordinator.language.name) or another language", text: $text, axis: .vertical).lineLimit(3...6).focused($focused).padding(18).background(.white, in: RoundedRectangle(cornerRadius: 22)).accessibilityIdentifier("typed-reply-input") + TextField("Reply in \(coordinator.language.name) or another language", text: Binding( + get: { text }, + set: { text = TextLimits.clampTypedReply($0) } + ), axis: .vertical).lineLimit(3...6).focused($focused).padding(18).background(.white, in: RoundedRectangle(cornerRadius: 22)).accessibilityIdentifier("typed-reply-input") .onChange(of: text) { _, _ in coordinator.noteTypingActivity() } + HStack { + Text("\(text.count)/\(TextLimits.typedReplyCharacters)") + .font(.footnote).foregroundStyle(MuralColor.secondary) + Spacer() + } if let error = coordinator.typedReplyError { Text(error).font(.footnote).foregroundStyle(MuralColor.secondary).fixedSize(horizontal: false, vertical: true).accessibilityIdentifier("typed-reply-error") } Button { sending = true; Task { let ok = await coordinator.sendTyped(text); sending = false; if ok { dismiss() } } } label: { HStack { Text(sending ? "Sending…" : "Send reply").fixedSize(horizontal: false, vertical: true); Spacer(); Image(systemName: "arrow.up") }.padding(18).background(MuralColor.orange, in: Capsule()) - }.disabled(sending || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty).accessibilityIdentifier("typed-reply-send").id("typed-reply-send") + }.disabled(!canSend).accessibilityIdentifier("typed-reply-send").id("typed-reply-send") Spacer() }.padding(26).frame(maxWidth: .infinity, alignment: .leading).foregroundStyle(MuralColor.ink) }.accessibilityIdentifier("typed-reply-scroll").background(MuralColor.cream) diff --git a/apps/ios/App/Storage.swift b/apps/ios/App/Storage.swift index 1b56e76..12bd7f2 100644 --- a/apps/ios/App/Storage.swift +++ b/apps/ios/App/Storage.swift @@ -63,7 +63,7 @@ import MuralCore guard let index = archive.sessions.firstIndex(where: { $0.id == sessionID }), let passage = archive.sessions[index].passages.first(where: { $0.id == passageID && $0.speaker == .user }) else { return } for (offset, fragment) in passage.fragments.enumerated() { - archive.sessions[index].correctFragment(id: fragment.id, text: offset == 0 ? String(text.prefix(10_000)) : "") + archive.sessions[index].correctFragment(id: fragment.id, text: offset == 0 ? TextLimits.clampCorrection(text) : "") } onSessionInvalidation?(sessionID) persist() diff --git a/apps/ios/Core/TextLimits.swift b/apps/ios/Core/TextLimits.swift new file mode 100644 index 0000000..856c1b0 --- /dev/null +++ b/apps/ios/Core/TextLimits.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Shared caps for learner-authored text. Both clients must refuse or stop accepting +/// input past these lengths instead of silently truncating on save/send. +public enum TextLimits { + public static let typedReplyCharacters = 2_000 + public static let correctionCharacters = 10_000 + + public static func clampTypedReply(_ text: String) -> String { + String(text.prefix(typedReplyCharacters)) + } + + public static func clampCorrection(_ text: String) -> String { + String(text.prefix(correctionCharacters)) + } + + public static func typedReplyExceedsLimit(_ text: String) -> Bool { + text.count > typedReplyCharacters + } + + public static func correctionExceedsLimit(_ text: String) -> Bool { + text.count > correctionCharacters + } +} diff --git a/apps/ios/Tests/LearningTests.swift b/apps/ios/Tests/LearningTests.swift index 8dc622a..6c3f75d 100644 --- a/apps/ios/Tests/LearningTests.swift +++ b/apps/ios/Tests/LearningTests.swift @@ -2,6 +2,16 @@ import XCTest @testable import MuralCore final class LearningTests: XCTestCase { + func testTextLimitsMatchSharedCapsAndRefuseSilentOverflow() { + XCTAssertEqual(TextLimits.typedReplyCharacters, 2_000) + XCTAssertEqual(TextLimits.correctionCharacters, 10_000) + let reply = String(repeating: "a", count: 2_001) + XCTAssertTrue(TextLimits.typedReplyExceedsLimit(reply)) + XCTAssertEqual(TextLimits.clampTypedReply(reply).count, 2_000) + let correction = String(repeating: "b", count: 10_001) + XCTAssertTrue(TextLimits.correctionExceedsLimit(correction)) + XCTAssertEqual(TextLimits.clampCorrection(correction).count, 10_000) + } func fixture(day: Double = 0, theme: String = "walk", supported: Bool = false, kind: EvidenceKind = .independent) -> SessionRecord { let date = Date(timeIntervalSince1970: 1_780_000_000 + day * 86400) var s = SessionRecord(themeID: theme) diff --git a/scripts/check_cross_platform.py b/scripts/check_cross_platform.py index d1899e2..5616deb 100644 --- a/scripts/check_cross_platform.py +++ b/scripts/check_cross_platform.py @@ -297,6 +297,12 @@ def check_prompts(swift_path, kotlin_path): ('idle_voice_s', 'scalar', ('apps/ios/Core/SessionLimits.swift', r'idleVoiceSeconds: Double = (\d[\d_]*(?:\.\d[\d_]*)?)'), ('apps/android/app/src/main/java/chat/mural/core/SessionLimits.kt', r'IDLE_VOICE_SECONDS = (\d[\d_]*(?:\.\d[\d_]*)?)')), + ('typed_reply_characters', 'scalar', + ('apps/ios/Core/TextLimits.swift', r'typedReplyCharacters = (\d[\d_]*)'), + ('apps/android/app/src/main/java/chat/mural/core/TextLimits.kt', r'TYPED_REPLY_CHARACTERS = (\d[\d_]*)')), + ('correction_characters', 'scalar', + ('apps/ios/Core/TextLimits.swift', r'correctionCharacters = (\d[\d_]*)'), + ('apps/android/app/src/main/java/chat/mural/core/TextLimits.kt', r'CORRECTION_CHARACTERS = (\d[\d_]*)')), ]