Skip to content

Commit 5f1f0bb

Browse files
committed
Recover from a damaged Japanese dictionary and stop stale word speech
A dictionary file that fails to load as a zip or with an I/O error is now deleted, so the next attempt downloads it again instead of failing the same way forever. A failed or interrupted download no longer leaves its .part file behind, and the whole transfer is bounded by a 10 minute call timeout. Moving the selection from a word that is being recorded or played to a phrase now stops that word's speech, so it cannot start late. Spec: docs/specs/2026-09-30-dictionary-recovery-and-speech-stop.md Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKhgrZwwyTSRbNNCiRGy6s
1 parent 19879c0 commit 5f1f0bb

6 files changed

Lines changed: 204 additions & 20 deletions

File tree

‎app/src/main/java/com/kienhoang/dualsubreplay/data/JapaneseDictionaryStore.kt‎

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -65,28 +65,41 @@ internal class JapaneseDictionaryStore(
6565
internal fun installFrom(input: InputStream): Boolean {
6666
directory.mkdirs()
6767
val partial = File(directory, JapaneseDictionaryRelease.FILE_NAME + ".part")
68-
val digest = MessageDigest.getInstance("SHA-256")
69-
var total = 0L
70-
partial.outputStream().use { output ->
71-
val buffer = ByteArray(BUFFER_BYTES)
72-
while (total <= expectedSize) {
73-
val read = input.read(buffer)
74-
if (read < 0) break
75-
total += read
76-
digest.update(buffer, 0, read)
77-
output.write(buffer, 0, read)
68+
var installed = false
69+
try {
70+
val digest = MessageDigest.getInstance("SHA-256")
71+
var total = 0L
72+
partial.outputStream().use { output ->
73+
val buffer = ByteArray(BUFFER_BYTES)
74+
while (total <= expectedSize) {
75+
val read = input.read(buffer)
76+
if (read < 0) break
77+
total += read
78+
digest.update(buffer, 0, read)
79+
output.write(buffer, 0, read)
80+
}
7881
}
82+
val sha256 = digest.digest().joinToString("") { "%02x".format(it) }
83+
installed = total == expectedSize && sha256.equals(expectedSha256, ignoreCase = true) && partial.renameTo(file)
84+
return installed
85+
} finally {
86+
// Also after a dropped connection or a failed write: no partial file stays behind.
87+
if (!installed) partial.delete()
7988
}
80-
val sha256 = digest.digest().joinToString("") { "%02x".format(it) }
81-
if (total != expectedSize || !sha256.equals(expectedSha256, ignoreCase = true) || !partial.renameTo(file)) {
82-
partial.delete()
83-
return false
84-
}
85-
return true
8689
}
8790

88-
/** Builds Kuromoji's analyzer from the installed dictionary. Takes about a second and ~50 MB. */
89-
fun loadTokenizer(): Tokenizer = ZipFile(file).use { zip -> ZipDictionaryBuilder(zip).build() }
91+
/**
92+
* Builds Kuromoji's analyzer from the installed dictionary. Takes about a second and ~50 MB.
93+
* A file that cannot be read as the dictionary is deleted, so the next attempt downloads it
94+
* again instead of failing the same way forever. Running out of memory keeps the file.
95+
*/
96+
fun loadTokenizer(): Tokenizer =
97+
try {
98+
ZipFile(file).use { zip -> ZipDictionaryBuilder(zip).build() }
99+
} catch (error: IOException) {
100+
file.delete()
101+
throw error
102+
}
90103

91104
/**
92105
* Kuromoji 0.9.0's IPADIC builder always reads the dictionary from its own classpath package.
@@ -122,6 +135,8 @@ internal class JapaneseDictionaryStore(
122135
.Builder()
123136
.connectTimeout(20, TimeUnit.SECONDS)
124137
.readTimeout(60, TimeUnit.SECONDS)
138+
// Bounds the whole 13 MB transfer, so a connection that trickles cannot hold the one download forever.
139+
.callTimeout(10, TimeUnit.MINUTES)
125140
.build()
126141
}
127142

‎app/src/main/java/com/kienhoang/dualsubreplay/ui/PronunciationCache.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@ internal class PronunciationCache(
2121
language: String,
2222
): File? = audio?.takeIf { ready && key == keyOf(text, language) && it.isFile && it.length() > 0 }
2323

24+
/** Whether the cached (or still recording) speech is for [text] in [language]. */
25+
fun holds(
26+
text: String,
27+
language: String,
28+
): Boolean = key == keyOf(text, language)
29+
2430
/** Deletes the cached speech unless it belongs to [text] in [language]. */
2531
fun keepOnly(
2632
text: String,
2733
language: String,
2834
) {
29-
if (key != keyOf(text, language)) clear()
35+
if (!holds(text, language)) clear()
3036
}
3137

3238
/**

‎app/src/main/java/com/kienhoang/dualsubreplay/ui/WordPronouncer.kt‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,15 @@ internal class WordPronouncer(context: Context) {
5050
}
5151
}
5252

53-
/** Drops the recorded speech of the last word unless the learner is still on [word]. */
53+
/**
54+
* Drops the recorded speech of the last word unless the learner is still on [word], and stops
55+
* that word's speech if it is still being recorded or played, so it cannot start late.
56+
*/
5457
fun forgetUnless(
5558
word: String,
5659
language: String,
5760
) {
61+
if (!cache.holds(word, language)) stop()
5862
cache.keepOnly(word, language)
5963
}
6064

‎app/src/test/java/com/kienhoang/dualsubreplay/data/JapaneseDictionaryStoreTest.kt‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,41 @@ class JapaneseDictionaryStoreTest {
7979
assertTrue(store(directory) { error("must not download again") }.install())
8080
}
8181

82+
@Test
83+
fun aDroppedConnectionLeavesNoPartialFile() {
84+
val directory = folder.newFolder("dictionary")
85+
val dropping =
86+
object : java.io.InputStream() {
87+
private var sent = 0
88+
89+
override fun read(): Int = if (sent++ < 8) 1 else throw IOException("connection reset")
90+
}
91+
92+
assertFalse(store(directory) { dropping }.install())
93+
assertTrue(directory.listFiles().orEmpty().isEmpty())
94+
}
95+
96+
@Test
97+
fun aCorruptInstalledDictionaryIsDownloadedAgainAfterItFailsToLoad() {
98+
val directory = folder.newFolder("dictionary")
99+
// Same size as the real file, so the cheap size check still calls it installed.
100+
File(directory, JapaneseDictionaryRelease.FILE_NAME).writeBytes(ByteArray(dictionaryJar.length().toInt()))
101+
var downloads = 0
102+
val store =
103+
store(directory) {
104+
downloads++
105+
dictionaryJar.inputStream()
106+
}
107+
assertTrue(store.isInstalled())
108+
109+
assertTrue(runCatching { store.loadTokenizer() }.exceptionOrNull() is IOException)
110+
assertFalse(store.isInstalled())
111+
112+
assertTrue(store.install())
113+
assertEquals(1, downloads)
114+
assertEquals(listOf("日本語"), japaneseLearnerWords(store.loadTokenizer(), "日本語").map { it.text })
115+
}
116+
82117
@Test
83118
fun releasePinMatchesTheMavenArtifactName() {
84119
assertTrue(

‎app/src/test/java/com/kienhoang/dualsubreplay/ui/PronunciationCacheTest.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ class PronunciationCacheTest {
5555
assertNull(cache.lookup("春", "ja"))
5656
}
5757

58+
@Test
59+
fun aWordBeingRecordedBelongsToItsSelectionUntilAnotherIsChosen() {
60+
val cache = PronunciationCache(folder.newFolder("speech"))
61+
assertFalse(cache.holds("春", "ja"))
62+
63+
// Recording has started but not finished: extending the selection to a phrase must stop it.
64+
cache.prepare("春", "ja")
65+
assertTrue(cache.holds(" 春 ", "ja"))
66+
assertFalse(cache.holds("春ですね", "ja"))
67+
assertFalse(cache.holds("春", "en"))
68+
69+
cache.keepOnly("春ですね", "ja")
70+
assertFalse(cache.holds("春", "ja"))
71+
}
72+
5873
@Test
5974
fun anUnfinishedRecordingIsNeverReplayed() {
6075
val cache = PronunciationCache(folder.newFolder("speech"))
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Dictionary recovers from a bad file; a phrase selection stops the word's speech
2+
3+
## Status
4+
5+
Implemented. Approved for implementation by the owner on 2026-09-30 ("do what you recommend",
6+
after the review of the external Sol 6.1 audit, items 4 and 5 in their smaller recommended
7+
form). Local checks pass; see the PR for the final-head CI.
8+
9+
## Context / problem
10+
11+
Three small problems, reproduced on 2026-09-30 by compiling the unchanged
12+
`JapaneseDictionaryStore.kt` with a harness, or confirmed by reading the code:
13+
14+
1. **A same-size corrupt dictionary never recovers.** `isInstalled()` checks only the file's size.
15+
If the stored jar is damaged after a verified install (storage corruption), every load fails with
16+
`ZipException`, the file stays, and every retry fails the same way. Japanese keeps the heuristic
17+
tokenizer for good.
18+
2. **An interrupted download leaves a `.part` file.** `installFrom` deleted the partial file only on
19+
its normal path, so a dropped connection left up to 13 MB behind until the next attempt
20+
overwrote it.
21+
3. **The download had no overall deadline.** Connect (20 s) and read (60 s) timeouts do not bound a
22+
connection that keeps trickling bytes, and only one download runs at a time.
23+
4. **A word's speech could start after the selection became a phrase.** With "Pronounce tapped
24+
words" on, tapping a word starts recording and playing it. Extending the selection to a phrase
25+
called `WordPronouncer.forgetUnless`, which cleared the cache but left the speech job running,
26+
so the word could still play a moment later.
27+
28+
## Goals
29+
30+
- A dictionary that fails to load as a zip or with an I/O error is deleted, so the next retry
31+
downloads it again. Running out of memory keeps the file.
32+
- No partial file remains after any failed download.
33+
- The whole transfer is bounded (OkHttp call timeout, 10 minutes for 13 MB).
34+
- Moving the selection away from the word being pronounced stops that word's speech.
35+
36+
## Non-goals
37+
38+
- No hashing on every start, no new status model or retry UI, no Wi-Fi-only rule (the owner chose
39+
first-use download on any network).
40+
- No change to phrases never auto-speaking, the explicit Pronounce action, reuse of the last word's
41+
recording, or the tap-inside-selection-to-clear behavior.
42+
- `MediaPlayer.prepare()` on the small local recording stays synchronous.
43+
44+
## User-visible behavior
45+
46+
- **Before:** a damaged dictionary file left Japanese on heuristic word splitting permanently; a
47+
tapped word could still speak after extending the selection to a phrase.
48+
- **After:** the dictionary downloads again on the next retry (about a minute later, or the next
49+
launch); extending a selection silences the word right away.
50+
51+
## Technical constraints / invariants
52+
53+
- Keep the pinned size and SHA-256 check and the atomic rename; an intact file is never replaced by
54+
a failed download (downloads only start when the file is missing or was just deleted).
55+
- Plain JUnit4 tests through the existing injectable `open` function.
56+
57+
## Proposed approach / plan
58+
59+
1. `installFrom`: `try/finally` deletes `.part` unless the rename succeeded.
60+
2. `loadTokenizer`: on `IOException` (including `ZipException`), delete the file and rethrow.
61+
3. OkHttp client: `callTimeout(10, MINUTES)`.
62+
4. `PronunciationCache.holds`; `WordPronouncer.forgetUnless` calls `stop()` when the cache is not for
63+
the new selection.
64+
65+
## Acceptance criteria
66+
67+
- [x] A dropped connection returns false and leaves the dictionary folder empty.
68+
- [x] A same-size corrupt file fails to load, is no longer installed, and the next `install()`
69+
downloads once and loads.
70+
- [x] Existing dictionary tests (verified install, mirror fallback, damaged/oversized rejects, no
71+
second download) pass.
72+
- [x] The cache reports which word it holds while recording, so a phrase selection stops it.
73+
- [ ] Owner's phone: tap a Japanese word, then drag to a phrase before it speaks; the word stays
74+
silent (not run).
75+
76+
## Validation plan
77+
78+
| Category | Command/scenario and expected result | Environment / applicability |
79+
| --- | --- | --- |
80+
| Unit tests | `./gradlew testDebugUnitTest`, including new `JapaneseDictionaryStoreTest` and `PronunciationCacheTest` cases | Local Linux, CI |
81+
| Android lint/build | `formatCheck complexityCheck lintDebug assembleDebug assembleDebugAndroidTest` | Local Linux, CI |
82+
| Managed-device/emulator | Existing suite | CI |
83+
| Physical-device/manual | Word → phrase during speech; real first-use download | Owner's phone (not run) |
84+
85+
## Risks / edge cases
86+
87+
- An `IOException` while reading a healthy file (for example the storage being unmounted) now also
88+
deletes it, costing one extra 13 MB download. That is rare and self-healing.
89+
- Moving the selection to another word or phrase also clears a speech message left from the previous
90+
selection (for example "No voice is available"), since it no longer describes what is selected.
91+
92+
## Release intent
93+
94+
`release:patch` (repository default for a bug fix). The version number depends on merge order with
95+
the other audit PRs and is an estimate until reserved.
96+
97+
## Implementation result
98+
99+
As planned.
100+
101+
## Validation result
102+
103+
- `./gradlew formatCheck complexityCheck testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest`:
104+
passed locally (Linux, JDK 21, Android SDK 36).
105+
- With `JapaneseDictionaryStore.kt` reverted, `aDroppedConnectionLeavesNoPartialFile` and
106+
`aCorruptInstalledDictionaryIsDownloadedAgainAfterItFailsToLoad` fail; with the fix both pass.
107+
- The speech stop itself runs on Android's text-to-speech and `MediaPlayer`, so it is covered only
108+
through `PronunciationCache.holds`; the phone check above is not run.
109+
- Final-head CI: see the PR.

0 commit comments

Comments
 (0)