Skip to content

Commit 19879c0

Browse files
Merge pull request #87 from hoangkien1703/claude/word-audio-jumpback-ja-segmentation-gf7qej
Speak tapped words, add a jump-back pill, and select whole Japanese words
2 parents c450524 + 5f6754b commit 19879c0

27 files changed

Lines changed: 1676 additions & 57 deletions

‎app/build.gradle.kts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,36 @@ if (requireReleaseSigning && !hasReleaseSigning) {
3838
)
3939
}
4040

41+
// The exact artifact the app downloads, packaged into the device-test APK so the device test
42+
// installs and loads it without network.
43+
val japaneseDictionary by configurations.creating { isTransitive = false }
44+
45+
abstract class JapaneseTestDictionary : DefaultTask() {
46+
@get:InputFiles
47+
abstract val artifact: ConfigurableFileCollection
48+
49+
@get:OutputDirectory
50+
abstract val output: DirectoryProperty
51+
52+
@TaskAction
53+
fun copy() {
54+
artifact.singleFile.copyTo(output.file("japanese-dictionary.jar").get().asFile, overwrite = true)
55+
}
56+
}
57+
58+
val japaneseTestDictionary by tasks.registering(JapaneseTestDictionary::class) {
59+
artifact.from(japaneseDictionary)
60+
output.set(layout.buildDirectory.dir("generated/japanese-test-dictionary"))
61+
}
62+
63+
androidComponents {
64+
onVariants { variant ->
65+
variant.deviceTests.values.forEach { deviceTest ->
66+
deviceTest.sources.assets?.addGeneratedSourceDirectory(japaneseTestDictionary, JapaneseTestDictionary::output)
67+
}
68+
}
69+
}
70+
4171
android {
4272
namespace = "com.kienhoang.dualsubreplay"
4373
compileSdk = 36
@@ -155,6 +185,14 @@ android {
155185
resources.excludes += setOf(
156186
"/META-INF/{AL2.0,LGPL2.1}",
157187
"META-INF/DEPENDENCIES",
188+
// Kuromoji's 13 MB dictionary is downloaded on first Japanese use (JapaneseDictionaryStore).
189+
"com/atilika/kuromoji/ipadic/*.bin",
190+
)
191+
// Both Kuromoji jars ship the same license, notice and contributor files.
192+
resources.pickFirsts += setOf(
193+
"META-INF/CONTRIBUTORS.md",
194+
"META-INF/LICENSE.md",
195+
"META-INF/NOTICE.md",
158196
)
159197
}
160198

@@ -196,6 +234,10 @@ dependencies {
196234
implementation("com.google.mlkit:translate:17.0.3")
197235
}
198236
implementation("com.squareup.okhttp3:okhttp:4.12.0")
237+
// Japanese morphological analysis (MeCab IPADIC dictionary), Apache-2.0, pure Java.
238+
// The APK keeps only its code; the dictionary files are excluded from packaging below.
239+
implementation("com.atilika.kuromoji:kuromoji-ipadic:0.9.0")
240+
japaneseDictionary("com.atilika.kuromoji:kuromoji-ipadic:0.9.0")
199241
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
200242

201243
debugImplementation("androidx.compose.ui:ui-tooling")

‎app/proguard-rules.pro‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@
22
# mode otherwise removes their no-argument constructors (verified in release QA).
33
-keep,allowoptimization class com.google.mlkit.nl.translate.NaturalLanguageTranslateRegistrar { public <init>(); }
44
-keep,allowoptimization class com.google.mlkit.common.internal.CommonComponentRegistrar { public <init>(); }
5+
6+
# Kuromoji is small (about 100 KB of code). Keep it whole: its dictionary is downloaded later, so a
7+
# shrinking mistake would only show up on a phone, after the download.
8+
-keep class com.atilika.kuromoji.** { *; }
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.kienhoang.dualsubreplay.data
2+
3+
import androidx.test.platform.app.InstrumentationRegistry
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Assert.assertTrue
6+
import org.junit.Test
7+
import java.io.File
8+
9+
/**
10+
* The APK leaves out Kuromoji's dictionary. This installs the exact Maven artifact the app
11+
* downloads (packaged in the test APK, so no network), checks it against the pinned checksum,
12+
* and proves Kuromoji loads it on ART.
13+
*/
14+
class JapaneseMorphologyDeviceTest {
15+
@Test
16+
fun downloadedDictionaryVerifiesAndGroupsWholeWords() {
17+
val instrumentation = InstrumentationRegistry.getInstrumentation()
18+
val directory = File(instrumentation.targetContext.cacheDir, "japanese-dictionary-test").apply { deleteRecursively() }
19+
val store = JapaneseDictionaryStore(directory, open = { instrumentation.context.assets.open("japanese-dictionary.jar") })
20+
21+
assertTrue("The pinned size and SHA-256 must match the Maven artifact", store.install())
22+
23+
val words = japaneseLearnerWords(store.loadTokenizer(), "毎日お母さんに手伝ってもらって、").map { it.text }
24+
assertEquals(listOf("毎日", "お母さん", "に", "手伝ってもらって", "、"), words)
25+
directory.deleteRecursively()
26+
}
27+
}

‎app/src/androidTest/java/com/kienhoang/dualsubreplay/ui/SubtitleUiTest.kt‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import androidx.compose.ui.semantics.SemanticsProperties
1616
import androidx.compose.ui.test.SemanticsMatcher
1717
import androidx.compose.ui.test.assert
1818
import androidx.compose.ui.test.assertIsDisplayed
19+
import androidx.compose.ui.test.performTouchInput
20+
import androidx.compose.ui.test.swipeUp
1921
import androidx.compose.ui.test.junit4.v2.createComposeRule
2022
import androidx.compose.ui.test.onNodeWithText
2123
import androidx.compose.ui.test.onNodeWithTag
@@ -219,4 +221,55 @@ class SubtitleUiTest {
219221
assertTrue("active original stays on screen: $original in ${viewport()}", fits(original))
220222
assertTrue("active translation stays on screen: $translation in ${viewport()}", fits(translation))
221223
}
224+
225+
@Test
226+
fun jumpBackPillShowsWhileScrollingAwayAndReturnsToTheSpokenLine() {
227+
val segments =
228+
(0 until 40).map { index ->
229+
SubtitleSegment(
230+
id = index.toLong(),
231+
startMs = index * 2_000L,
232+
endMs = index * 2_000L + 1_900L,
233+
originalText = "Original line $index",
234+
translatedText = "Translated line $index",
235+
)
236+
}
237+
val state =
238+
DualSubUiState(
239+
segments = segments,
240+
currentIndex = 4,
241+
playbackPaused = true,
242+
wordHighlightEnabled = false,
243+
wordLearningEnabled = false,
244+
)
245+
composeRule.setContent {
246+
DualSubTheme {
247+
Box(Modifier.fillMaxWidth().height(360.dp).testTag("timeline_viewport")) {
248+
SubtitleTimeline(state, onReplay = {})
249+
}
250+
}
251+
}
252+
composeRule.waitForIdle()
253+
composeRule.onNodeWithText("Original line 4").assertIsDisplayed()
254+
composeRule.onNodeWithTag("jump_back_pill").assertDoesNotExist()
255+
256+
composeRule.mainClock.autoAdvance = false
257+
composeRule.onNodeWithTag("timeline_viewport").performTouchInput { swipeUp() }
258+
composeRule.mainClock.advanceTimeBy(800)
259+
composeRule.onNodeWithTag("jump_back_pill").assertExists()
260+
composeRule.onNodeWithText("Now playing · 0:08").assertExists()
261+
262+
// Stopping to read hides it; scrolling again brings it back.
263+
composeRule.mainClock.advanceTimeBy(JUMP_BACK_LINGER_MS + 3_000)
264+
composeRule.onNodeWithTag("jump_back_pill").assertDoesNotExist()
265+
composeRule.onNodeWithTag("timeline_viewport").performTouchInput { swipeUp() }
266+
composeRule.mainClock.advanceTimeBy(800)
267+
composeRule.onNodeWithTag("jump_back_pill").assertExists().performClick()
268+
269+
composeRule.mainClock.advanceTimeBy(3_000)
270+
composeRule.mainClock.autoAdvance = true
271+
composeRule.waitForIdle()
272+
composeRule.onNodeWithText("Original line 4").assertIsDisplayed()
273+
composeRule.onNodeWithTag("jump_back_pill").assertDoesNotExist()
274+
}
222275
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package com.kienhoang.dualsubreplay.data
2+
3+
import com.atilika.kuromoji.dict.CharacterDefinitions
4+
import com.atilika.kuromoji.dict.ConnectionCosts
5+
import com.atilika.kuromoji.dict.InsertedDictionary
6+
import com.atilika.kuromoji.dict.TokenInfoDictionary
7+
import com.atilika.kuromoji.dict.UnknownDictionary
8+
import com.atilika.kuromoji.ipadic.Tokenizer
9+
import com.atilika.kuromoji.trie.DoubleArrayTrie
10+
import com.atilika.kuromoji.util.ResourceResolver
11+
import okhttp3.OkHttpClient
12+
import okhttp3.Request
13+
import java.io.File
14+
import java.io.FileNotFoundException
15+
import java.io.IOException
16+
import java.io.InputStream
17+
import java.security.MessageDigest
18+
import java.util.concurrent.TimeUnit
19+
import java.util.zip.ZipFile
20+
21+
/** The published Kuromoji IPADIC artifact the app downloads, pinned by size and SHA-256. */
22+
internal object JapaneseDictionaryRelease {
23+
const val FILE_NAME = "kuromoji-ipadic-0.9.0.jar"
24+
const val SIZE_BYTES = 13_343_016L
25+
const val SHA256 = "24909fd751c0b439f7af5131b080eb65bc85062f0c4977ca4a71c76abe74e0b6"
26+
private const val PATH = "com/atilika/kuromoji/kuromoji-ipadic/0.9.0/kuromoji-ipadic-0.9.0.jar"
27+
28+
/** Maven Central, then Google's mirror of it. The checksum makes the host irrelevant to integrity. */
29+
val URLS =
30+
listOf(
31+
"https://repo1.maven.org/maven2/$PATH",
32+
"https://maven-central.storage-download.googleapis.com/maven2/$PATH",
33+
)
34+
}
35+
36+
/**
37+
* Keeps the Japanese dictionary in app storage. The APK carries Kuromoji's code but not its
38+
* 13 MB dictionary, so only people who watch Japanese download it, once. The file is the
39+
* unchanged Maven artifact; Kuromoji reads its dictionary entries straight from it.
40+
*/
41+
internal class JapaneseDictionaryStore(
42+
private val directory: File,
43+
private val expectedSize: Long = JapaneseDictionaryRelease.SIZE_BYTES,
44+
private val expectedSha256: String = JapaneseDictionaryRelease.SHA256,
45+
private val open: (url: String) -> InputStream = ::openWithOkHttp,
46+
) {
47+
private val file: File get() = File(directory, JapaneseDictionaryRelease.FILE_NAME)
48+
49+
fun isInstalled(): Boolean = file.isFile && file.length() == expectedSize
50+
51+
/** Downloads the dictionary unless it is already installed. Blocking; returns whether it is installed. */
52+
fun install(): Boolean {
53+
if (isInstalled()) return true
54+
return JapaneseDictionaryRelease.URLS.any { url ->
55+
try {
56+
open(url).use(::installFrom)
57+
} catch (_: Exception) {
58+
// Offline, blocked or a broken response: try the next host.
59+
false
60+
}
61+
}
62+
}
63+
64+
/** Saves [input] as the dictionary only if it is exactly the expected file. */
65+
internal fun installFrom(input: InputStream): Boolean {
66+
directory.mkdirs()
67+
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)
78+
}
79+
}
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
86+
}
87+
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() }
90+
91+
/**
92+
* Kuromoji 0.9.0's IPADIC builder always reads the dictionary from its own classpath package.
93+
* This does the same loading steps, but reads each file from the downloaded jar instead.
94+
*/
95+
private class ZipDictionaryBuilder(
96+
private val zip: ZipFile,
97+
) : Tokenizer.Builder() {
98+
override fun loadDictionaries() {
99+
// IPADIC's default search-mode penalties; normal mode, used here, does not apply them.
100+
penalties = arrayListOf(2, 3000, 7, 1700)
101+
val files =
102+
ResourceResolver { name ->
103+
val entry = zip.getEntry(DICTIONARY_PACKAGE + name) ?: throw FileNotFoundException(name)
104+
zip.getInputStream(entry)
105+
}
106+
resolver = files
107+
doubleArrayTrie = DoubleArrayTrie.newInstance(files)
108+
connectionCosts = ConnectionCosts.newInstance(files)
109+
tokenInfoDictionary = TokenInfoDictionary.newInstance(files)
110+
characterDefinitions = CharacterDefinitions.newInstance(files)
111+
unknownDictionary = UnknownDictionary.newInstance(files, characterDefinitions, totalFeatures)
112+
insertedDictionary = InsertedDictionary(totalFeatures)
113+
}
114+
}
115+
116+
private companion object {
117+
const val BUFFER_BYTES = 64 * 1024
118+
const val DICTIONARY_PACKAGE = "com/atilika/kuromoji/ipadic/"
119+
120+
val client: OkHttpClient by lazy {
121+
OkHttpClient
122+
.Builder()
123+
.connectTimeout(20, TimeUnit.SECONDS)
124+
.readTimeout(60, TimeUnit.SECONDS)
125+
.build()
126+
}
127+
128+
fun openWithOkHttp(url: String): InputStream {
129+
val response = client.newCall(Request.Builder().url(url).build()).execute()
130+
if (!response.isSuccessful) {
131+
response.close()
132+
throw IOException("The Japanese dictionary download failed (${response.code}).")
133+
}
134+
return response.body?.byteStream() ?: throw IOException("The Japanese dictionary download was empty.")
135+
}
136+
}
137+
}

0 commit comments

Comments
 (0)