feat: add local hexagram content for offline reading

Load a versioned Wikisource jing plus project-authored plain drafts so results can show labeled original and vernacular texts without unauthorized modern translations.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
QiuSW
2026-08-19 11:48:16 +08:00
co-authored by Cursor
parent 4235cf9011
commit 534c88993e
33 changed files with 5626 additions and 29 deletions
+5
View File
@@ -57,6 +57,11 @@ android {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
sourceSets {
getByName("main").assets.srcDir(rootProject.file("content/packages"))
getByName("test").resources.srcDir(rootProject.file("content/packages"))
}
testOptions {
unitTests.isReturnDefaultValues = true
}
@@ -76,14 +76,33 @@ class MainActivityTest {
fun autoSaveSettingIsRealAndReflectedOnHome() {
openHome()
composeRule.onNodeWithText("管理保存设置").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag("auto_save_history").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("auto_save_history").assertIsOn().performClick()
composeRule.onNodeWithTag("auto_save_history").assertIsOff()
composeRule.waitUntil(timeoutMillis = 5_000) {
runCatching {
composeRule.onNodeWithTag("auto_save_history").assertIsOff()
true
}.getOrDefault(false)
}
composeRule.onNodeWithText("返回").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("自动保存完整记录:已关闭").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithText("自动保存完整记录:已关闭").assertIsDisplayed()
composeRule.onNodeWithText("管理保存设置").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag("auto_save_history").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("auto_save_history").performClick()
composeRule.onNodeWithTag("auto_save_history").assertIsOn()
composeRule.waitUntil(timeoutMillis = 5_000) {
runCatching {
composeRule.onNodeWithTag("auto_save_history").assertIsOn()
true
}.getOrDefault(false)
}
}
@Test
@@ -12,6 +12,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import net.opcapp.flash.feature.casting.CastingScreen
import net.opcapp.flash.feature.casting.CastingViewModel
import net.opcapp.flash.feature.content.ContentSourcesScreen
import net.opcapp.flash.feature.home.HomeScreen
import net.opcapp.flash.feature.home.HomeViewModel
import net.opcapp.flash.feature.onboarding.MethodIntroScreen
@@ -27,6 +28,7 @@ private object Destination {
const val Casting = "casting"
const val Result = "result"
const val Settings = "settings"
const val Sources = "sources"
}
@Composable
@@ -78,6 +80,7 @@ fun LingjiApp(
},
onOpenSettings = { navController.navigate(Destination.Settings) },
onOpenMethodIntro = { navController.navigate(Destination.Welcome) },
onOpenContentSources = { navController.navigate(Destination.Sources) },
)
}
composable(Destination.Question) {
@@ -127,5 +130,11 @@ fun LingjiApp(
onBack = navController::popBackStack,
)
}
composable(Destination.Sources) {
ContentSourcesScreen(
manifest = homeState.contentManifest,
onBack = navController::popBackStack,
)
}
}
}
@@ -0,0 +1,34 @@
package net.opcapp.flash.core.model
data class ContentSource(
val id: String,
val title: String,
val edition: String,
val license: String,
val url: String,
) {
init {
require(id.isNotBlank()) { "Source id must not be blank" }
require(title.isNotBlank()) { "Source title must not be blank" }
require(edition.isNotBlank()) { "Source edition must not be blank" }
require(license.isNotBlank()) { "Source license must not be blank" }
require(url.isNotBlank()) { "Source url must not be blank" }
}
}
data class ContentManifest(
val contentVersion: String,
val digest: String,
val sources: List<ContentSource>,
val loadError: String? = null,
) {
init {
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
require(loadError == null || loadError.isNotBlank()) {
"Content load error must be non-blank or null"
}
require(sources.map(ContentSource::id).distinct().size == sources.size) {
"Content sources must have unique ids"
}
}
}
@@ -0,0 +1,55 @@
package net.opcapp.flash.data.content
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.HexagramContent
@Singleton
class AssetsHexagramContentRepository @Inject constructor(
@ApplicationContext context: Context,
) : HexagramContentRepository {
private val parsed: Result<ParsedHexagramContentPackage> by lazy {
runCatching {
context.assets.open(ASSET_NAME).bufferedReader(Charsets.UTF_8).use { reader ->
HexagramContentParser.parse(reader.readText())
}
}
}
override val contentVersion: String
get() = parsed.getOrNull()?.manifest?.contentVersion ?: UNAVAILABLE_VERSION
override val manifest: ContentManifest
get() = parsed.getOrNull()?.manifest ?: ContentManifest(
contentVersion = UNAVAILABLE_VERSION,
digest = "unavailable",
sources = emptyList(),
loadError = parsed.exceptionOrNull()?.message ?: "Content package could not be loaded",
)
override fun contentFor(
kingWenNumber: Int,
requestedContentVersion: String,
): HexagramContent {
val pack = parsed.getOrElse { error ->
throw ContentIntegrityException(
error.message ?: "Content package failed integrity checks",
)
}
if (requestedContentVersion != pack.manifest.contentVersion) {
throw ContentIntegrityException(
"Requested content version '$requestedContentVersion' is unavailable; loaded '${pack.manifest.contentVersion}'",
)
}
return pack.hexagrams[kingWenNumber]
?: throw ContentIntegrityException("No validated content for King Wen number $kingWenNumber")
}
companion object {
const val ASSET_NAME = "hexagram-content.json"
const val UNAVAILABLE_VERSION = "unavailable"
}
}
@@ -0,0 +1,340 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.ContentSource
import net.opcapp.flash.core.model.HexagramContent
import net.opcapp.flash.core.model.HexagramNames
import net.opcapp.flash.domain.casting.HexagramCatalog
import net.opcapp.flash.domain.casting.HexagramPattern
import net.opcapp.flash.domain.casting.Polarity
data class ParsedHexagramContentPackage(
val manifest: ContentManifest,
val hexagrams: Map<Int, HexagramContent>,
)
object HexagramContentParser {
private val unsafeText =
Regex("(?:<\\s*script\\b|javascript\\s*:|\\bon\\w+\\s*=|[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f])")
fun parse(json: String): ParsedHexagramContentPackage {
val root = try {
JsonReader(json).readValue() as? JsonValue.Obj
?: throw ContentIntegrityException("Content package must be a JSON object")
} catch (error: ContentIntegrityException) {
throw error
} catch (error: IllegalStateException) {
throw ContentIntegrityException(error.message ?: "Content package JSON is invalid")
}
val errors = mutableListOf<String>()
val schemaVersion = root.int("schemaVersion", errors)
if (schemaVersion != 1) errors += "schemaVersion must be 1"
val contentVersion = root.text("contentVersion", errors)
val usage = root.obj("specialUsageTexts", errors)
val qianUsage = usage?.bool("qian", errors)
val kunUsage = usage?.bool("kun", errors)
val sourceNodes = root.arr("sources", errors)
val sources = mutableListOf<ContentSource>()
val sourceIds = mutableSetOf<String>()
sourceNodes?.forEachIndexed { index, node ->
val prefix = "sources[$index]"
val obj = node as? JsonValue.Obj
if (obj == null) {
errors += "$prefix must be an object"
return@forEachIndexed
}
val id = obj.text("id", errors, prefix)
val title = obj.text("title", errors, prefix)
val edition = obj.text("edition", errors, prefix)
val license = obj.text("license", errors, prefix)
val url = obj.text("url", errors, prefix)
if (id != null && !sourceIds.add(id)) errors += "$prefix.id must be unique"
if (url != null && !isHttpUrl(url)) errors += "$prefix.url must be an absolute HTTP(S) URL"
if (id != null && title != null && edition != null && license != null && url != null) {
sources += ContentSource(id, title, edition, license, url)
}
}
if (sourceNodes != null && sourceNodes.isEmpty()) {
errors += "sources must contain at least one licensed source"
}
val hexagramNodes = root.arr("hexagrams", errors)
if (hexagramNodes != null && hexagramNodes.size != 64) {
errors += "hexagrams must contain exactly 64 entries"
}
val hexagrams = mutableMapOf<Int, HexagramContent>()
val seenPatterns = mutableSetOf<String>()
hexagramNodes?.forEachIndexed { index, node ->
val prefix = "hexagrams[$index]"
val obj = node as? JsonValue.Obj
if (obj == null) {
errors += "$prefix must be an object"
return@forEachIndexed
}
val kingWenNumber = obj.int("kingWenNumber", errors, prefix)
val name = obj.text("name", errors, prefix)
val symbol = obj.text("symbol", errors, prefix)
val judgmentOriginal = obj.text("judgmentOriginal", errors, prefix)
val judgmentPlain = obj.text("judgmentPlain", errors, prefix)
val lineTexts = obj.sixTexts("lineTextsBottomUp", errors, prefix)
val linePlain = obj.sixTexts("linePlainBottomUp", errors, prefix)
val sourceRefs = obj.stringList("sourceRefs", errors, prefix)
val specialUsage = obj.optionalText("specialUsageText", errors, prefix)
val lower = obj.text("lowerTrigram", errors, prefix)
val upper = obj.text("upperTrigram", errors, prefix)
val patternTokens = obj.stringList("patternBottomUp", errors, prefix)
if (kingWenNumber != null && (kingWenNumber < 1 || kingWenNumber > 64)) {
errors += "$prefix.kingWenNumber must be an integer from 1 through 64"
}
if (kingWenNumber != null && hexagrams.containsKey(kingWenNumber)) {
errors += "$prefix.kingWenNumber must be unique"
}
if (kingWenNumber != null && name != null && name != HexagramNames.nameFor(kingWenNumber)) {
errors += "$prefix.name does not match the King Wen name table"
}
if (kingWenNumber != null && symbol != null && symbol != hexagramSymbol(kingWenNumber)) {
errors += "$prefix.symbol must be the King Wen unicode hexagram"
}
if (sourceRefs != null) {
if (sourceRefs.isEmpty()) errors += "$prefix.sourceRefs must not be empty"
if (sourceRefs.size != sourceRefs.distinct().size) {
errors += "$prefix.sourceRefs must be unique"
}
for (sourceRef in sourceRefs) {
if (sourceRef !in sourceIds) {
errors += "$prefix.sourceRefs contains unknown source '$sourceRef'"
}
}
}
if (patternTokens != null) {
if (patternTokens.size != 6 || patternTokens.any { it != "YIN" && it != "YANG" }) {
errors += "$prefix.patternBottomUp must contain exactly six YIN/YANG values"
} else {
val encoded = patternTokens.joinToString("/")
if (!seenPatterns.add(encoded)) errors += "$prefix.patternBottomUp must be unique"
val polarities = patternTokens.map { token ->
if (token == "YANG") Polarity.YANG else Polarity.YIN
}
val expectedId = HexagramCatalog.idFor(HexagramPattern.of(polarities)).value
if (kingWenNumber != null && kingWenNumber != expectedId) {
errors += "$prefix.kingWenNumber does not match its King Wen trigram pair"
}
val expectedPattern = expectedBottomUp(lower, upper)
if (expectedPattern != null && expectedPattern != patternTokens) {
errors += "$prefix.patternBottomUp does not match lower/upper trigrams in bottom-up order"
}
}
}
val hasSpecial = specialUsage != null
when (kingWenNumber) {
1 -> if (qianUsage != null && hasSpecial != qianUsage) {
errors += "$prefix.specialUsageText must match specialUsageTexts.qian"
}
2 -> if (kunUsage != null && hasSpecial != kunUsage) {
errors += "$prefix.specialUsageText must match specialUsageTexts.kun"
}
else -> if (hasSpecial) {
errors += "$prefix.specialUsageText is only valid for Qian or Kun"
}
}
if (
errors.none { it.startsWith(prefix) } &&
kingWenNumber != null &&
name != null &&
symbol != null &&
judgmentOriginal != null &&
judgmentPlain != null &&
lineTexts != null &&
linePlain != null &&
sourceRefs != null
) {
hexagrams[kingWenNumber] = HexagramContent(
kingWenNumber = kingWenNumber,
name = name,
symbol = symbol,
judgmentOriginal = judgmentOriginal,
judgmentPlain = judgmentPlain,
lineTextsBottomUp = lineTexts,
linePlainBottomUp = linePlain,
specialUsageText = specialUsage,
sourceRefs = sourceRefs,
)
}
}
if (hexagrams.size != 64 && errors.none { it.contains("exactly 64") }) {
errors += "King Wen numbers 1 through 64 must each occur exactly once"
}
if (errors.isNotEmpty()) {
throw ContentIntegrityException(errors.joinToString(separator = "\n"))
}
return ParsedHexagramContentPackage(
manifest = ContentManifest(
contentVersion = checkNotNull(contentVersion),
digest = jsonDigest(root),
sources = sources,
),
hexagrams = hexagrams,
)
}
private fun hexagramSymbol(kingWenNumber: Int): String =
(0x4DC0 + kingWenNumber - 1).toChar().toString()
private fun expectedBottomUp(lower: String?, upper: String?): List<String>? {
val lowerPattern = TRIGRAMS[lower] ?: return null
val upperPattern = TRIGRAMS[upper] ?: return null
return lowerPattern + upperPattern
}
private fun isHttpUrl(value: String): Boolean =
value.startsWith("https://") || value.startsWith("http://")
private fun JsonValue.Obj.int(key: String, errors: MutableList<String>, prefix: String = ""): Int? {
val path = fieldPath(prefix, key)
return when (val value = fields[key]) {
is JsonValue.Num -> value.value
null -> {
errors += "$path is missing"
null
}
else -> {
errors += "$path must be an integer"
null
}
}
}
private fun JsonValue.Obj.bool(key: String, errors: MutableList<String>, prefix: String = ""): Boolean? {
val path = fieldPath(prefix, key)
return when (val value = fields[key]) {
is JsonValue.Bool -> value.value
null -> {
errors += "$path is missing"
null
}
else -> {
errors += "$path must be a boolean"
null
}
}
}
private fun JsonValue.Obj.text(key: String, errors: MutableList<String>, prefix: String = ""): String? {
val path = fieldPath(prefix, key)
val value = optionalText(key, errors, prefix)
if (value == null && fields[key] !is JsonValue.Null) {
if (!fields.containsKey(key)) errors += "$path must be non-blank text"
} else if (value == null) {
errors += "$path must be non-blank text"
}
return value
}
private fun JsonValue.Obj.optionalText(
key: String,
errors: MutableList<String>,
prefix: String,
): String? {
val path = fieldPath(prefix, key)
return when (val value = fields[key]) {
JsonValue.Null, null -> null
is JsonValue.Str -> {
when {
value.value.isBlank() -> {
errors += "$path must be non-blank text or null"
null
}
unsafeText.containsMatchIn(value.value) -> {
errors += "$path contains script-like markup or an invisible control character"
null
}
else -> value.value
}
}
else -> {
errors += "$path must be non-blank text or null"
null
}
}
}
private fun JsonValue.Obj.obj(key: String, errors: MutableList<String>): JsonValue.Obj? {
return when (val value = fields[key]) {
is JsonValue.Obj -> value
else -> {
errors += "$key must be an object"
null
}
}
}
private fun JsonValue.Obj.arr(key: String, errors: MutableList<String>): List<JsonValue>? {
return when (val value = fields[key]) {
is JsonValue.Arr -> value.items
else -> {
errors += "$key must be an array"
null
}
}
}
private fun JsonValue.Obj.sixTexts(
key: String,
errors: MutableList<String>,
prefix: String,
): List<String>? {
val values = stringList(key, errors, prefix) ?: return null
if (values.size != 6) {
errors += "${fieldPath(prefix, key)} must contain exactly six bottom-up entries"
return null
}
return values
}
private fun JsonValue.Obj.stringList(
key: String,
errors: MutableList<String>,
prefix: String,
): List<String>? {
val path = fieldPath(prefix, key)
val array = fields[key] as? JsonValue.Arr
if (array == null) {
errors += "$path must be an array"
return null
}
val values = mutableListOf<String>()
array.items.forEachIndexed { index, item ->
val itemPath = "$path[$index]"
val text = (item as? JsonValue.Str)?.value
when {
text == null || text.isBlank() -> errors += "$itemPath must be non-blank text"
unsafeText.containsMatchIn(text) ->
errors += "$itemPath contains script-like markup or an invisible control character"
else -> values += text
}
}
return values.takeIf { it.size == array.items.size }
}
private fun fieldPath(prefix: String, key: String): String =
if (prefix.isEmpty()) key else "$prefix.$key"
private val TRIGRAMS = mapOf(
"QIAN" to listOf("YANG", "YANG", "YANG"),
"DUI" to listOf("YANG", "YANG", "YIN"),
"LI" to listOf("YANG", "YIN", "YANG"),
"ZHEN" to listOf("YANG", "YIN", "YIN"),
"XUN" to listOf("YIN", "YANG", "YANG"),
"KAN" to listOf("YIN", "YANG", "YIN"),
"GEN" to listOf("YIN", "YIN", "YANG"),
"KUN" to listOf("YIN", "YIN", "YIN"),
)
}
@@ -1,9 +1,11 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.HexagramContent
interface HexagramContentRepository {
val contentVersion: String
val manifest: ContentManifest
fun contentFor(
kingWenNumber: Int,
@@ -0,0 +1,202 @@
package net.opcapp.flash.data.content
import java.security.MessageDigest
internal sealed interface JsonValue {
data class Obj(val fields: Map<String, JsonValue>) : JsonValue
data class Arr(val items: List<JsonValue>) : JsonValue
data class Str(val value: String) : JsonValue
data class Num(val value: Int) : JsonValue
data class Bool(val value: Boolean) : JsonValue
data object Null : JsonValue
}
internal class JsonReader(private val text: String) {
private var index = 0
fun readValue(): JsonValue {
skipWhitespace()
val value = parseValue()
skipWhitespace()
if (index != text.length) {
error("Unexpected trailing content at index $index")
}
return value
}
private fun parseValue(): JsonValue {
skipWhitespace()
return when (val char = peek()) {
'{' -> parseObject()
'[' -> parseArray()
'"' -> JsonValue.Str(parseString())
't' -> parseLiteral("true", JsonValue.Bool(true))
'f' -> parseLiteral("false", JsonValue.Bool(false))
'n' -> parseLiteral("null", JsonValue.Null)
'-', in '0'..'9' -> parseNumber()
else -> error("Unexpected character '$char' at index $index")
}
}
private fun parseObject(): JsonValue.Obj {
expect('{')
skipWhitespace()
val fields = linkedMapOf<String, JsonValue>()
if (peek() == '}') {
index += 1
return JsonValue.Obj(fields)
}
while (true) {
skipWhitespace()
val key = parseString()
skipWhitespace()
expect(':')
skipWhitespace()
if (fields.containsKey(key)) error("Duplicate JSON key '$key'")
fields[key] = parseValue()
skipWhitespace()
when (peek()) {
',' -> index += 1
'}' -> {
index += 1
return JsonValue.Obj(fields)
}
else -> error("Expected comma or object end at index $index")
}
}
}
private fun parseArray(): JsonValue.Arr {
expect('[')
skipWhitespace()
val items = mutableListOf<JsonValue>()
if (peek() == ']') {
index += 1
return JsonValue.Arr(items)
}
while (true) {
items += parseValue()
skipWhitespace()
when (peek()) {
',' -> index += 1
']' -> {
index += 1
return JsonValue.Arr(items)
}
else -> error("Expected comma or array end at index $index")
}
}
}
private fun parseString(): String {
expect('"')
val builder = StringBuilder()
while (true) {
val char = next()
when (char) {
'"' -> return builder.toString()
'\\' -> builder.append(parseEscape())
in '\u0000'..'\u001F' -> error("Unescaped control character in string")
else -> builder.append(char)
}
}
}
private fun parseEscape(): Char {
return when (val char = next()) {
'"', '\\', '/' -> char
'b' -> '\b'
'f' -> '\u000C'
'n' -> '\n'
'r' -> '\r'
't' -> '\t'
'u' -> {
val hex = CharArray(4) { next() }.concatToString()
hex.toIntOrNull(16)?.toChar() ?: error("Invalid unicode escape \\u$hex")
}
else -> error("Invalid string escape \\$char")
}
}
private fun parseNumber(): JsonValue.Num {
val start = index
if (peek() == '-') index += 1
if (peek() == '0') {
index += 1
} else {
if (peek() !in '1'..'9') error("Invalid number at index $start")
while (peek() in '0'..'9') index += 1
}
if (peek() == '.' || peek() == 'e' || peek() == 'E') {
error("Content JSON numbers must be integers")
}
return JsonValue.Num(text.substring(start, index).toInt())
}
private fun parseLiteral(literal: String, value: JsonValue): JsonValue {
if (!text.startsWith(literal, index)) error("Expected '$literal' at index $index")
index += literal.length
return value
}
private fun skipWhitespace() {
while (index < text.length && text[index] in WHITESPACE) index += 1
}
private fun peek(): Char {
if (index >= text.length) error("Unexpected end of JSON")
return text[index]
}
private fun next(): Char {
val char = peek()
index += 1
return char
}
private fun expect(char: Char) {
if (next() != char) error("Expected '$char' at index ${index - 1}")
}
companion object {
private val WHITESPACE = charArrayOf(' ', '\n', '\r', '\t')
}
}
internal fun jsonCanonicalize(value: JsonValue): String = when (value) {
JsonValue.Null -> "null"
is JsonValue.Bool -> value.value.toString()
is JsonValue.Num -> value.value.toString()
is JsonValue.Str -> jsonQuote(value.value)
is JsonValue.Arr -> value.items.joinToString(",", "[", "]") { jsonCanonicalize(it) }
is JsonValue.Obj -> value.fields.keys.sorted().joinToString(",", "{", "}") { key ->
"${jsonQuote(key)}:${jsonCanonicalize(value.fields.getValue(key))}"
}
}
internal fun jsonDigest(value: JsonValue): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(jsonCanonicalize(value).toByteArray(Charsets.UTF_8))
return digest.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xFF) }
}
private fun jsonQuote(value: String): String {
val builder = StringBuilder("\"")
for (char in value) {
when (char) {
'"' -> builder.append("\\\"")
'\\' -> builder.append("\\\\")
'\b' -> builder.append("\\b")
'\u000C' -> builder.append("\\f")
'\n' -> builder.append("\\n")
'\r' -> builder.append("\\r")
'\t' -> builder.append("\\t")
else -> if (char.code < 0x20) {
builder.append("\\u%04x".format(char.code))
} else {
builder.append(char)
}
}
}
return builder.append('"').toString()
}
@@ -0,0 +1,19 @@
package net.opcapp.flash.data.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import net.opcapp.flash.data.content.AssetsHexagramContentRepository
import net.opcapp.flash.data.content.HexagramContentRepository
@Module
@InstallIn(SingletonComponent::class)
abstract class ContentBindingsModule {
@Binds
@Singleton
abstract fun bindHexagramContentRepository(
implementation: AssetsHexagramContentRepository,
): HexagramContentRepository
}
@@ -14,6 +14,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import net.opcapp.flash.core.model.HexagramContent
import net.opcapp.flash.data.content.ContentIntegrityException
import net.opcapp.flash.data.content.HexagramContentRepository
import net.opcapp.flash.data.history.HistoryRepository
import net.opcapp.flash.data.history.SavedCastingSession
import net.opcapp.flash.data.settings.HistorySettingsRepository
@@ -22,6 +25,7 @@ import net.opcapp.flash.domain.casting.CastMetadata
import net.opcapp.flash.domain.casting.CastResult
import net.opcapp.flash.domain.casting.CastRound
import net.opcapp.flash.domain.casting.CoinSide
import net.opcapp.flash.domain.casting.LineValue
sealed interface HistorySaveStatus {
data object Draft : HistorySaveStatus
@@ -52,6 +56,10 @@ data class CastingUiState(
val result: CastResult? = null,
val sessionId: String? = null,
val saveStatus: HistorySaveStatus = HistorySaveStatus.Draft,
val primaryContent: HexagramContent? = null,
val transformedContent: HexagramContent? = null,
val contentError: String? = null,
val showSpecialUsage: Boolean = false,
) {
val currentRoundNumber: Int
get() = (confirmedRounds.size + 1).coerceAtMost(6)
@@ -67,12 +75,14 @@ class CastingViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val historyRepository: HistoryRepository,
private val settingsRepository: HistorySettingsRepository,
private val contentRepository: HexagramContentRepository,
) : ViewModel() {
private val restoredRounds = decodeRounds(savedStateHandle[Keys.Rounds])
private val restoredCreatedAt = savedStateHandle.get<String>(Keys.CreatedAt)
private val restoredResult = restoredCreatedAt
?.takeIf { restoredRounds.size == 6 }
?.let { createdAt -> createResult(restoredRounds, createdAt) }
private val restoredContent = resolveContent(restoredResult)
private val _uiState = MutableStateFlow(
CastingUiState(
@@ -86,6 +96,10 @@ class CastingViewModel @Inject constructor(
} else {
HistorySaveStatus.Saving
},
primaryContent = restoredContent.primary,
transformedContent = restoredContent.transformed,
contentError = restoredContent.error,
showSpecialUsage = restoredContent.showSpecialUsage,
),
)
val uiState: StateFlow<CastingUiState> = _uiState.asStateFlow()
@@ -138,6 +152,7 @@ class CastingViewModel @Inject constructor(
val createdAt = Instant.now().toString()
val sessionId = UUID.randomUUID().toString()
val result = createResult(updatedRounds, createdAt)
val content = resolveContent(result)
savedStateHandle[Keys.CreatedAt] = createdAt
savedStateHandle[Keys.SessionId] = sessionId
_uiState.value = state.copy(
@@ -146,6 +161,10 @@ class CastingViewModel @Inject constructor(
result = result,
sessionId = sessionId,
saveStatus = HistorySaveStatus.Saving,
primaryContent = content.primary,
transformedContent = content.transformed,
contentError = content.error,
showSpecialUsage = content.showSpecialUsage,
)
persistResult(autoOnly = true)
return true
@@ -288,11 +307,51 @@ class CastingViewModel @Inject constructor(
CastResult.record(
computation = CastEngine.cast(rounds),
metadata = CastMetadata(
contentVersion = CONTENT_VERSION,
contentVersion = contentRepository.contentVersion,
createdAt = createdAt,
),
)
private fun resolveContent(result: CastResult?): ResolvedContent {
if (result == null) return ResolvedContent()
val showSpecialUsage = when (result.primaryHexagramId.value) {
1 -> result.lineValuesBottomUp.all { it == LineValue.OLD_YANG }
2 -> result.lineValuesBottomUp.all { it == LineValue.OLD_YIN }
else -> false
}
return try {
val primary = contentRepository.contentFor(
result.primaryHexagramId.value,
result.contentVersion,
)
val transformed = if (result.transformedHexagramId == result.primaryHexagramId) {
primary
} else {
contentRepository.contentFor(
result.transformedHexagramId.value,
result.contentVersion,
)
}
ResolvedContent(
primary = primary,
transformed = transformed,
showSpecialUsage = showSpecialUsage,
)
} catch (error: ContentIntegrityException) {
ResolvedContent(
error = error.message,
showSpecialUsage = showSpecialUsage,
)
}
}
private data class ResolvedContent(
val primary: HexagramContent? = null,
val transformed: HexagramContent? = null,
val error: String? = null,
val showSpecialUsage: Boolean = false,
)
private fun decodeRounds(scores: ArrayList<Int>?): List<CastRound> =
scores.orEmpty().chunked(CastRound.COINS_PER_ROUND).map(CastRound::fromScores)
@@ -311,7 +370,4 @@ class CastingViewModel @Inject constructor(
const val SessionId = "casting_session_id"
}
companion object {
const val CONTENT_VERSION = "hexagram-names-v1"
}
}
@@ -0,0 +1,121 @@
package net.opcapp.flash.feature.content
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import net.opcapp.flash.R
import net.opcapp.flash.core.model.ContentManifest
@Composable
fun ContentSourcesScreen(
manifest: ContentManifest,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onBack)
Surface(
modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(contentAlignment = Alignment.TopCenter) {
Column(
modifier = Modifier
.widthIn(max = 600.dp)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TextButton(
onClick = onBack,
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.back))
}
Text(
text = stringResource(R.string.content_sources_title),
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = stringResource(R.string.content_sources_lede),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.content_version_label, manifest.contentVersion),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = stringResource(R.string.content_digest_label, manifest.digest),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (manifest.loadError != null) {
Text(
text = stringResource(R.string.content_integrity_error),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
)
}
manifest.sources.forEach { source ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
shape = RoundedCornerShape(20.dp),
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = source.title, style = MaterialTheme.typography.titleLarge)
Text(
text = stringResource(R.string.content_source_edition, source.edition),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.content_source_license, source.license),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.content_source_url, source.url),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Text(
text = stringResource(R.string.content_review_pending),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -40,6 +40,7 @@ fun HomeScreen(
onStartCasting: () -> Unit,
onOpenSettings: () -> Unit,
onOpenMethodIntro: () -> Unit,
onOpenContentSources: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
@@ -134,6 +135,12 @@ fun HomeScreen(
) {
Text(stringResource(R.string.open_method_intro))
}
TextButton(
onClick = onOpenContentSources,
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.open_content_sources))
}
}
}
Spacer(Modifier.height(16.dp))
@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.data.content.HexagramContentRepository
import net.opcapp.flash.data.history.HistoryRepository
import net.opcapp.flash.data.history.SavedSessionSummary
import net.opcapp.flash.data.settings.HistorySavePolicy
@@ -20,13 +22,20 @@ data class HomeUiState(
val savePolicy: HistorySavePolicy = HistorySavePolicy(),
val hasSeenMethodIntro: Boolean = false,
val isReady: Boolean = false,
val contentManifest: ContentManifest = ContentManifest(
contentVersion = "unavailable",
digest = "unavailable",
sources = emptyList(),
),
)
@HiltViewModel
class HomeViewModel @Inject constructor(
historyRepository: HistoryRepository,
private val settingsRepository: HistorySettingsRepository,
contentRepository: HexagramContentRepository,
) : ViewModel() {
private val contentManifest = contentRepository.manifest
val uiState: StateFlow<HomeUiState> = combine(
historyRepository.observeCount(),
historyRepository.observeLatest(),
@@ -39,6 +48,7 @@ class HomeViewModel @Inject constructor(
savePolicy = savePolicy,
hasSeenMethodIntro = hasSeenMethodIntro,
isReady = true,
contentManifest = contentManifest,
)
}.stateIn(
scope = viewModelScope,
@@ -45,6 +45,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import net.opcapp.flash.R
import net.opcapp.flash.core.model.HexagramContent
import net.opcapp.flash.core.model.HexagramNames
import net.opcapp.flash.domain.casting.CastResult
import net.opcapp.flash.domain.casting.LineValue
@@ -94,6 +95,10 @@ fun CastResultScreen(
ResultContent(
result = result,
saveStatus = uiState.saveStatus,
primaryContent = uiState.primaryContent,
transformedContent = uiState.transformedContent,
contentError = uiState.contentError,
showSpecialUsage = uiState.showSpecialUsage,
onSave = onSave,
onDelete = onDelete,
onRetry = onRetry,
@@ -110,6 +115,10 @@ fun CastResultScreen(
private fun ResultContent(
result: CastResult,
saveStatus: HistorySaveStatus,
primaryContent: HexagramContent?,
transformedContent: HexagramContent?,
contentError: String?,
showSpecialUsage: Boolean,
onSave: () -> Unit,
onDelete: () -> Unit,
onRetry: () -> Unit,
@@ -175,6 +184,13 @@ private fun ResultContent(
}
}
RawLinesCard(result.lineValuesBottomUp)
HexagramTextsCard(
result = result,
primaryContent = primaryContent,
transformedContent = transformedContent,
contentError = contentError,
showSpecialUsage = showSpecialUsage,
)
SaveStatusCard(
status = saveStatus,
onSave = onSave,
@@ -182,7 +198,7 @@ private fun ResultContent(
onRetry = onRetry,
)
Text(
text = stringResource(R.string.content_package_pending),
text = stringResource(R.string.content_plain_draft_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -197,6 +213,104 @@ private fun ResultContent(
}
}
@Composable
private fun HexagramTextsCard(
result: CastResult,
primaryContent: HexagramContent?,
transformedContent: HexagramContent?,
contentError: String?,
showSpecialUsage: Boolean,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(20.dp),
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (contentError != null || primaryContent == null) {
Text(
text = stringResource(R.string.content_integrity_error),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
)
return@Column
}
LabeledPassage(
title = stringResource(R.string.primary_judgment_title),
original = primaryContent.judgmentOriginal,
plain = primaryContent.judgmentPlain,
)
if (result.movingLinePositions.isNotEmpty()) {
Text(
text = stringResource(R.string.moving_line_texts_title),
style = MaterialTheme.typography.titleLarge,
)
result.movingLinePositions.forEach { position ->
LabeledPassage(
title = linePositionName(position),
original = primaryContent.lineTextsBottomUp[position - 1],
plain = primaryContent.linePlainBottomUp[position - 1],
)
}
}
if (
transformedContent != null &&
result.transformedHexagramId != result.primaryHexagramId
) {
LabeledPassage(
title = stringResource(R.string.transformed_judgment_title),
original = transformedContent.judgmentOriginal,
plain = transformedContent.judgmentPlain,
)
}
if (showSpecialUsage && primaryContent.specialUsageText != null) {
LabeledPassage(
title = stringResource(R.string.special_usage_title),
original = primaryContent.specialUsageText,
plain = null,
)
}
}
}
}
@Composable
private fun LabeledPassage(
title: String,
original: String,
plain: String?,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
)
Text(
text = stringResource(R.string.content_original_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = original,
style = MaterialTheme.typography.bodyLarge,
)
if (plain != null) {
Text(
text = stringResource(R.string.content_plain_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = plain,
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
@Composable
private fun RawLinesCard(lines: List<LineValue>) {
Card(
+17 -1
View File
@@ -103,7 +103,23 @@
<string name="save_this_time">保存本次</string>
<string name="do_not_save_this_time">本次不保存</string>
<string name="retry">重试</string>
<string name="content_package_pending">当前显示可复核的卦号、卦名和六爻事实。经典原文与现代白话将在来源和授权审核完成后接入。</string>
<string name="content_original_label">原文</string>
<string name="content_plain_label">本地白话</string>
<string name="content_plain_draft_notice">白话为项目自撰草稿,供本机阅读;内容负责人审校前不视为可再分发。</string>
<string name="primary_judgment_title">本卦卦辞</string>
<string name="transformed_judgment_title">之卦卦辞</string>
<string name="moving_line_texts_title">动爻爻辞</string>
<string name="special_usage_title">用九 / 用六</string>
<string name="content_integrity_error">本地内容包未能通过完整性检查,卦辞暂不可读。卦号、卦名和六爻事实仍可复核。</string>
<string name="open_content_sources">内容来源</string>
<string name="content_sources_title">内容来源</string>
<string name="content_sources_lede">应用只使用已写入安装包的本地文本。原文来自可核验公版《易经》;白话由本项目撰写,审校完成前标为草稿。</string>
<string name="content_version_label">内容版本:%1$s</string>
<string name="content_digest_label">校验摘要:%1$s</string>
<string name="content_source_license">许可:%1$s</string>
<string name="content_source_edition">版本:%1$s</string>
<string name="content_source_url">来源:%1$s</string>
<string name="content_review_pending">内容负责人尚未确认本包可再分发;发布前须完成审校。</string>
<string name="start_again">再起一卦</string>
<string name="result_missing">结果状态已丢失,请返回首页重新开始。</string>
<string name="delete_current_record_title">删除本次本机记录?</string>
@@ -1,13 +1,23 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.ContentManifest
import net.opcapp.flash.core.model.ContentSource
import net.opcapp.flash.core.model.HexagramContent
class FakeHexagramContentRepository(
override val contentVersion: String = "fixture-only-v1",
entries: List<HexagramContent>,
sources: List<ContentSource> = emptyList(),
digest: String = "fixture-digest",
) : HexagramContentRepository {
private val entriesById: Map<Int, HexagramContent>
override val manifest: ContentManifest = ContentManifest(
contentVersion = contentVersion,
digest = digest,
sources = sources,
)
init {
require(contentVersion.isNotBlank()) { "Content version must not be blank" }
require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) {
@@ -0,0 +1,39 @@
package net.opcapp.flash.data.content
import net.opcapp.flash.core.model.HexagramNames
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class HexagramContentParserTest {
@Test
fun publishedPackageMatchesKingWenNamesAndSpecialUsage() {
val json = checkNotNull(javaClass.getResource("/hexagram-content.json")).readText()
val pack = HexagramContentParser.parse(json)
assertEquals("zh-Hans-2026.1", pack.manifest.contentVersion)
assertEquals(64, pack.hexagrams.size)
assertTrue(pack.manifest.digest.matches(Regex("[a-f0-9]{64}")))
assertEquals("复", pack.hexagrams.getValue(24).name)
assertEquals(HexagramNames.nameFor(24), pack.hexagrams.getValue(24).name)
assertEquals("䷗", pack.hexagrams.getValue(24).symbol)
assertTrue(pack.hexagrams.getValue(1).specialUsageText.orEmpty().contains("用九"))
assertTrue(pack.hexagrams.getValue(2).specialUsageText.orEmpty().contains("用六"))
assertNotNull(pack.manifest.sources.singleOrNull { it.id.contains("wikisource") })
assertNotNull(pack.manifest.sources.singleOrNull { it.id.contains("lingji-plain") })
}
@Test
fun parserRejectsWrongKingWenName() {
val json = checkNotNull(javaClass.getResource("/hexagram-content.json")).readText()
.replace("\"name\": \"复\"", "\"name\": \"錯\"")
try {
HexagramContentParser.parse(json)
fail("Expected content integrity failure")
} catch (error: ContentIntegrityException) {
assertTrue(error.message.orEmpty().contains("name"))
}
}
}