Compare commits

..
4 Commits
Author SHA1 Message Date
QiuSWandCursor 534c88993e 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>
2026-08-19 11:48:16 +08:00
QiuSWandCursor 4235cf9011 feat: add offline casting flow and local history sessions
Replace the development fixture shell with method intro, question, coin entry, and result screens. Persist locked casts in private Room storage under the save policy, and document remaining P2 content and P4 history-list gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 10:58:09 +08:00
QiuSW d82ab836a2 test: add reproducible device verification 2026-08-08 09:19:40 +08:00
QiuSW 730bc528be feat: add installable Lingji Android shell 2026-08-07 18:00:38 +08:00
87 changed files with 9414 additions and 154 deletions
+4
View File
@@ -23,6 +23,10 @@ jobs:
with:
node-version: "22"
package-manager-cache: false
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install pinned Android SDK components
run: sdkmanager "platforms;android-34" "build-tools;34.0.0"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Run local quality gates
+2
View File
@@ -4,6 +4,8 @@
*.iml
local.properties
captures/
hs_err_pid*.log
replay_pid*.log
# Signing material and local credentials must never enter the repository.
*.jks
+8 -8
View File
@@ -30,19 +30,19 @@ tools are unavailable or insufficient. State the fallback in the handoff.
## Current build boundary
The repository uses `brainwave` only as a code name. Formal product name,
organization-owned application ID, and final `minSdk` remain product decisions.
Until they are accepted, the `app` project is a Kotlin/JVM domain harness, not a
publishable Android application. Do not invent placeholder release identity.
The repository keeps `brainwave` only as its code name. The accepted Android
identity is product name `灵机`, namespace/application ID `net.opcapp.flash`, and
`minSdk` 26. The `app` project is an installable Android application; the app icon
and store assets remain product decisions and must not be silently finalized.
## Verification
Run from the repository root:
```powershell
.\gradlew.bat verifyLocal --offline
.\gradlew.bat verifyLocal
```
After the Android application plugin is configured, also run the Android gates
listed in `docs/quality-gates.md`. Report skipped device or Android checks
explicitly; do not imply they passed.
`verifyLocal` includes Android lint, debug unit tests, APK assembly, repository
checks, and dependency-license coverage. Run `connectedDebugAndroidTest` separately
when an authorized device is present, and report skipped device checks explicitly.
+132 -15
View File
@@ -1,27 +1,121 @@
import groovy.json.JsonSlurper
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt)
}
android {
namespace = "net.opcapp.flash"
compileSdk = 34
defaultConfig {
applicationId = "net.opcapp.flash"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
javaCompileOptions {
annotationProcessorOptions {
arguments += mapOf("room.schemaLocation" to "$projectDir/schemas")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
buildFeatures {
compose = true
buildConfig = false
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
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
}
}
kotlin {
jvmToolchain(17)
sourceSets {
main {
kotlin.srcDir("src/main/java")
}
test {
kotlin.srcDir("src/test/java")
}
}
}
kapt {
correctErrorTypes = true
}
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions.jvmTarget = "17"
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(composeBom)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
kapt(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
testImplementation(libs.junit)
androidTestImplementation(composeBom)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.room.testing)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
tasks.test {
tasks.withType<Test>().configureEach {
useJUnit()
maxParallelForks = 1
maxHeapSize = "192m"
jvmArgs("-XX:MaxMetaspaceSize=128m", "-XX:+UseSerialGC")
testLogging {
events("failed", "skipped")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
@@ -30,25 +124,48 @@ tasks.test {
tasks.register("verifyDependencyLicenses") {
group = "verification"
description = "Ensures every resolved JVM runtime/test artifact has a reviewed license entry."
description = "Ensures every resolved debug runtime/test artifact has a reviewed license entry."
doLast {
@Suppress("UNCHECKED_CAST")
val manifest = JsonSlurper().parse(rootProject.file("config/dependency-licenses.json")) as Map<String, Any>
@Suppress("UNCHECKED_CAST")
val components = manifest.getValue("components") as List<Map<String, Any>>
@Suppress("UNCHECKED_CAST")
val groupPolicies = manifest.getValue("groupPolicies") as List<Map<String, Any>>
val reviewedCoordinates = components.mapNotNull { it["coordinates"] as String? }.toSet()
val resolvedCoordinates = configurations.getByName("testRuntimeClasspath")
val resolvedCoordinates = listOf(
"debugRuntimeClasspath",
"debugUnitTestRuntimeClasspath",
"debugAndroidTestRuntimeClasspath",
)
.flatMap { configurationName ->
configurations.getByName(configurationName)
.resolvedConfiguration
.resolvedArtifacts
.map { artifact ->
.mapNotNull { artifact ->
if (artifact.id.componentIdentifier !is org.gradle.api.artifacts.component.ModuleComponentIdentifier) {
return@mapNotNull null
}
val id = artifact.moduleVersion.id
"${id.group}:${id.name}:${id.version}"
}
}
.toSet()
val missing = resolvedCoordinates - reviewedCoordinates
val missing = resolvedCoordinates.filterNot { coordinates ->
if (coordinates in reviewedCoordinates) return@filterNot true
val group = coordinates.substringBefore(":")
groupPolicies.any { policy ->
val prefix = policy.getValue("groupPrefix") as String
group == prefix || group.startsWith("$prefix.")
}
}
check(missing.isEmpty()) {
"Dependencies missing reviewed license entries: ${missing.sorted().joinToString()}"
}
logger.lifecycle(
"Dependency license coverage passed for ${resolvedCoordinates.size} artifacts " +
"with ${groupPolicies.size} reviewed group policies.",
)
}
}
+1
View File
@@ -0,0 +1 @@
# Project-specific R8 rules will be added when release minification is enabled.
@@ -0,0 +1,128 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "29d4064d6ad955275a848abef712ab97",
"entities": [
{
"tableName": "casting_sessions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `created_at_epoch_millis` INTEGER NOT NULL, `created_at_text` TEXT NOT NULL, `method_version` TEXT NOT NULL, `content_version` TEXT NOT NULL, `coin_convention` TEXT NOT NULL, `rounds_json` TEXT NOT NULL, `line_values` TEXT NOT NULL, `primary_pattern` TEXT NOT NULL, `moving_positions` TEXT NOT NULL, `transformed_pattern` TEXT NOT NULL, `primary_hexagram_id` INTEGER NOT NULL, `transformed_hexagram_id` INTEGER NOT NULL, `question_text` TEXT, `question_saved` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAtEpochMillis",
"columnName": "created_at_epoch_millis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAtText",
"columnName": "created_at_text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "methodVersion",
"columnName": "method_version",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentVersion",
"columnName": "content_version",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "coinConvention",
"columnName": "coin_convention",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "roundsJson",
"columnName": "rounds_json",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lineValues",
"columnName": "line_values",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "primaryPattern",
"columnName": "primary_pattern",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "movingPositions",
"columnName": "moving_positions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "transformedPattern",
"columnName": "transformed_pattern",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "primaryHexagramId",
"columnName": "primary_hexagram_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "transformedHexagramId",
"columnName": "transformed_hexagram_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "questionText",
"columnName": "question_text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "questionSaved",
"columnName": "question_saved",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_casting_sessions_created_at_epoch_millis",
"unique": false,
"columnNames": [
"created_at_epoch_millis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_casting_sessions_created_at_epoch_millis` ON `${TABLE_NAME}` (`created_at_epoch_millis`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '29d4064d6ad955275a848abef712ab97')"
]
}
}
@@ -0,0 +1,141 @@
package net.opcapp.flash.app
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.printToString
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.runBlocking
import net.opcapp.flash.data.di.DebugPersistenceEntryPoint
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
@Before
fun resetLocalData() = runBlocking {
val dependencies = testDependencies()
dependencies.database().clearAllTables()
dependencies.settings().setHasSeenMethodIntro(true)
dependencies.settings().setAutoSaveHistory(true)
dependencies.settings().setSaveQuestionText(true)
dependencies.settings().setSaveExplanationContent(true)
dependencies.settings().setSaveActionNote(true)
}
@Test
fun completeCastingIsCalculatedAndSavedLocally() {
openHome()
composeRule.onNodeWithText("灵机").assertIsDisplayed()
composeRule.onNodeWithText("开始一问").performClick()
composeRule.onNodeWithTag("question_input").performTextInput("我该先验证哪个假设?")
composeRule.onNodeWithText("开始录入铜币").performClick()
selectRound(characterIndices = emptySet())
repeat(5) { selectRound(characterIndices = setOf(0)) }
composeRule.onNodeWithText("复 24 → 坤 2").assertIsDisplayed()
composeRule.onNodeWithText("动爻:初爻").assertIsDisplayed()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("已自动保存完整记录到本机")
.fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("do_not_save_this_time").performScrollTo().performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithTag("confirm_delete_record")
.fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("confirm_delete_record").performScrollTo().performClick()
try {
composeRule.waitUntil(timeoutMillis = 15_000) {
composeRule.onAllNodesWithText("本次尚未保存").fetchSemanticsNodes().isNotEmpty() ||
composeRule.onAllNodesWithText("删除失败,请重试").fetchSemanticsNodes().isNotEmpty()
}
} catch (error: Throwable) {
throw AssertionError(composeRule.onRoot().printToString(), error)
}
composeRule.onNodeWithText("本次尚未保存").assertIsDisplayed()
}
@Test
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.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.waitUntil(timeoutMillis = 5_000) {
runCatching {
composeRule.onNodeWithTag("auto_save_history").assertIsOn()
true
}.getOrDefault(false)
}
}
@Test
fun methodIntroRemainsAvailableFromHome() {
openHome()
composeRule.onNodeWithText("方法说明").performClick()
composeRule.onNodeWithText("你投币,应用记录;AI 不参与起卦。").assertIsDisplayed()
composeRule.onNodeWithText("字为 2,背为 3;第一次是初爻,由下而上。").assertIsDisplayed()
composeRule.onNodeWithTag("method_intro_continue").performClick()
composeRule.onNodeWithText("开始一问").assertIsDisplayed()
}
private fun openHome() {
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("开始一问").fetchSemanticsNodes().isNotEmpty() ||
composeRule.onAllNodesWithTag("method_intro_continue").fetchSemanticsNodes().isNotEmpty()
}
if (composeRule.onAllNodesWithTag("method_intro_continue").fetchSemanticsNodes().isNotEmpty()) {
composeRule.onNodeWithTag("method_intro_continue").performClick()
}
composeRule.onNodeWithText("开始一问").assertIsDisplayed()
}
private fun selectRound(characterIndices: Set<Int>) {
repeat(3) { index ->
val side = if (index in characterIndices) "character" else "reverse"
composeRule.onNodeWithTag("coin_${index}_$side").performClick()
}
composeRule.onNodeWithTag("confirm_round").performClick()
}
private fun testDependencies(): DebugPersistenceEntryPoint = EntryPointAccessors.fromApplication(
composeRule.activity.applicationContext,
DebugPersistenceEntryPoint::class.java,
)
}
@@ -0,0 +1,78 @@
package net.opcapp.flash.data.history
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.opcapp.flash.data.settings.HistorySavePolicy
import net.opcapp.flash.domain.casting.CastEngine
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.LineValue
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LingjiDatabaseTest {
private lateinit var database: LingjiDatabase
private lateinit var repository: RoomHistoryRepository
@Before
fun createDatabase() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, LingjiDatabase::class.java).build()
repository = RoomHistoryRepository(database, database.castingSessionDao())
}
@After
fun closeDatabase() {
database.close()
}
@Test
fun completedSessionCanBeSavedReadAndDeleted() = runBlocking {
val result = fixtureResult()
repository.saveSession(
id = "room-fixture",
result = result,
questionText = "我该先验证哪个假设?",
policy = HistorySavePolicy(),
)
val restored = repository.findById("room-fixture")
assertNotNull(restored)
assertEquals(result, restored?.result)
assertEquals("我该先验证哪个假设?", restored?.questionText)
assertEquals(1, repository.observeCount().first())
assertEquals(24, repository.observeLatest().first()?.primaryHexagramId)
assertEquals(true, repository.deleteById("room-fixture"))
assertFalse(repository.exists("room-fixture"))
}
private fun fixtureResult(): CastResult = CastResult.record(
computation = CastEngine.cast(
listOf(
CastRound.fromLineValue(LineValue.OLD_YANG),
CastRound.fromLineValue(LineValue.YOUNG_YIN),
CastRound.fromLineValue(LineValue.YOUNG_YIN),
CastRound.fromLineValue(LineValue.YOUNG_YIN),
CastRound.fromLineValue(LineValue.YOUNG_YIN),
CastRound.fromLineValue(LineValue.YOUNG_YIN),
),
),
metadata = CastMetadata(
contentVersion = "hexagram-names-v1",
createdAt = "2026-08-08T00:00:00Z",
),
)
}
@@ -0,0 +1,15 @@
package net.opcapp.flash.data.di
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import net.opcapp.flash.data.history.LingjiDatabase
import net.opcapp.flash.data.settings.HistorySettingsRepository
@EntryPoint
@InstallIn(SingletonComponent::class)
interface DebugPersistenceEntryPoint {
fun database(): LingjiDatabase
fun settings(): HistorySettingsRepository
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".app.LingjiApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Lingji"
android:usesCleartextTraffic="false">
<activity
android:name=".app.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,140 @@
package net.opcapp.flash.app
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
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
import net.opcapp.flash.feature.question.QuestionScreen
import net.opcapp.flash.feature.result.CastResultScreen
import net.opcapp.flash.feature.settings.HistorySettingsScreen
import net.opcapp.flash.feature.settings.HistorySettingsViewModel
private object Destination {
const val Welcome = "welcome"
const val Home = "home"
const val Question = "question"
const val Casting = "casting"
const val Result = "result"
const val Settings = "settings"
const val Sources = "sources"
}
@Composable
fun LingjiApp(
castingViewModel: CastingViewModel,
homeViewModel: HomeViewModel,
settingsViewModel: HistorySettingsViewModel,
navController: NavHostController = rememberNavController(),
) {
val castingState = castingViewModel.uiState.collectAsStateWithLifecycle().value
val homeState = homeViewModel.uiState.collectAsStateWithLifecycle().value
val savePolicy = settingsViewModel.policy.collectAsStateWithLifecycle().value
if (!homeState.isReady) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {}
return
}
NavHost(
navController = navController,
startDestination = if (homeState.hasSeenMethodIntro) {
Destination.Home
} else {
Destination.Welcome
},
) {
composable(Destination.Welcome) {
MethodIntroScreen(
showBack = navController.previousBackStackEntry != null,
onContinue = {
homeViewModel.markMethodIntroSeen()
navController.navigate(Destination.Home) {
popUpTo(Destination.Welcome) { inclusive = true }
launchSingleTop = true
}
},
onBack = { navController.popBackStack() },
)
}
composable(Destination.Home) {
HomeScreen(
uiState = homeState,
onStartCasting = {
castingViewModel.startNewSession()
navController.navigate(Destination.Question)
},
onOpenSettings = { navController.navigate(Destination.Settings) },
onOpenMethodIntro = { navController.navigate(Destination.Welcome) },
onOpenContentSources = { navController.navigate(Destination.Sources) },
)
}
composable(Destination.Question) {
QuestionScreen(
question = castingState.question,
onQuestionChanged = castingViewModel::updateQuestion,
onContinue = { navController.navigate(Destination.Casting) },
onBack = navController::popBackStack,
)
}
composable(Destination.Casting) {
CastingScreen(
uiState = castingState,
onSelectCoin = castingViewModel::selectCoin,
onConfirmRound = {
if (castingViewModel.confirmCurrentRound()) {
navController.navigate(Destination.Result)
}
},
onEditRound = castingViewModel::editFrom,
onBack = navController::popBackStack,
)
}
composable(Destination.Result) {
CastResultScreen(
uiState = castingState,
onBackHome = { navController.popBackStack(Destination.Home, inclusive = false) },
onStartAgain = {
castingViewModel.startNewSession()
navController.navigate(Destination.Question) {
popUpTo(Destination.Home)
launchSingleTop = true
}
},
onSave = castingViewModel::saveManually,
onDelete = castingViewModel::deleteSavedSession,
onRetry = castingViewModel::retryLastPersistenceOperation,
)
}
composable(Destination.Settings) {
HistorySettingsScreen(
policy = savePolicy,
onAutoSaveChanged = settingsViewModel::setAutoSaveHistory,
onSaveQuestionChanged = settingsViewModel::setSaveQuestionText,
onSaveExplanationChanged = settingsViewModel::setSaveExplanationContent,
onSaveActionChanged = settingsViewModel::setSaveActionNote,
onBack = navController::popBackStack,
)
}
composable(Destination.Sources) {
ContentSourcesScreen(
manifest = homeState.contentManifest,
onBack = navController::popBackStack,
)
}
}
}
@@ -0,0 +1,7 @@
package net.opcapp.flash.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class LingjiApplication : Application()
@@ -0,0 +1,31 @@
package net.opcapp.flash.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import dagger.hilt.android.AndroidEntryPoint
import net.opcapp.flash.core.designsystem.LingjiTheme
import net.opcapp.flash.feature.casting.CastingViewModel
import net.opcapp.flash.feature.home.HomeViewModel
import net.opcapp.flash.feature.settings.HistorySettingsViewModel
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val castingViewModel: CastingViewModel by viewModels()
private val homeViewModel: HomeViewModel by viewModels()
private val settingsViewModel: HistorySettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LingjiTheme {
LingjiApp(
castingViewModel = castingViewModel,
homeViewModel = homeViewModel,
settingsViewModel = settingsViewModel,
)
}
}
}
}
@@ -0,0 +1,84 @@
package net.opcapp.flash.core.designsystem
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
private val Paper = Color(0xFFF5F0E6)
private val RaisedPaper = Color(0xFFFFFBF3)
private val Ink = Color(0xFF26231F)
private val MutedInk = Color(0xFF665F55)
private val Cinnabar = Color(0xFFA33A2B)
private val DarkPaper = Color(0xFF1E1B18)
private val DarkRaisedPaper = Color(0xFF292520)
private val PaleInk = Color(0xFFEAE2D5)
private val PaleCinnabar = Color(0xFFFFB4A5)
private val LightColors = lightColorScheme(
primary = Cinnabar,
onPrimary = Color.White,
primaryContainer = Color(0xFFF4DAD3),
onPrimaryContainer = Color(0xFF3D0902),
background = Paper,
onBackground = Ink,
surface = RaisedPaper,
onSurface = Ink,
surfaceVariant = Color(0xFFE8E0D3),
onSurfaceVariant = MutedInk,
outline = Color(0xFF81786C),
)
private val DarkColors = darkColorScheme(
primary = PaleCinnabar,
onPrimary = Color(0xFF5F160C),
primaryContainer = Color(0xFF7F261A),
onPrimaryContainer = Color(0xFFFFDAD2),
background = DarkPaper,
onBackground = PaleInk,
surface = DarkRaisedPaper,
onSurface = PaleInk,
surfaceVariant = Color(0xFF4C453D),
onSurfaceVariant = Color(0xFFD1C6B8),
outline = Color(0xFF9C9184),
)
private val LingjiTypography = Typography().let { defaults ->
defaults.copy(
displaySmall = defaults.displaySmall.copy(
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.SemiBold,
),
headlineMedium = defaults.headlineMedium.copy(
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.Medium,
),
titleLarge = defaults.titleLarge.copy(
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.Medium,
),
bodyLarge = defaults.bodyLarge.copy(fontFamily = FontFamily.SansSerif),
bodyMedium = defaults.bodyMedium.copy(fontFamily = FontFamily.SansSerif),
labelLarge = defaults.labelLarge.copy(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.SemiBold,
),
)
}
@Composable
fun LingjiTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = LingjiTypography,
content = content,
)
}
@@ -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"
}
}
}
@@ -1,4 +1,4 @@
package brainwave.core.model
package net.opcapp.flash.core.model
data class HexagramContent(
val kingWenNumber: Int,
@@ -0,0 +1,24 @@
package net.opcapp.flash.core.model
import net.opcapp.flash.domain.casting.HexagramId
object HexagramNames {
private val names = listOf(
"乾", "坤", "屯", "蒙", "需", "讼", "师", "比",
"小畜", "履", "泰", "否", "同人", "大有", "谦", "豫",
"随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复",
"无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒",
"遁", "大壮", "晋", "明夷", "家人", "睽", "蹇", "解",
"损", "益", "夬", "姤", "萃", "升", "困", "井",
"革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅",
"巽", "兑", "涣", "节", "中孚", "小过", "既济", "未济",
)
init {
check(names.size == 64) { "Hexagram name table must contain 64 names" }
}
fun nameFor(id: HexagramId): String = names[id.value - 1]
fun nameFor(id: Int): String = nameFor(HexagramId(id))
}
@@ -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 brainwave.data.content
package net.opcapp.flash.data.content
import brainwave.core.model.HexagramContent
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
}
@@ -0,0 +1,40 @@
package net.opcapp.flash.data.di
import android.content.Context
import androidx.room.Room
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import net.opcapp.flash.data.history.CastingSessionDao
import net.opcapp.flash.data.history.HistoryRepository
import net.opcapp.flash.data.history.LingjiDatabase
import net.opcapp.flash.data.history.RoomHistoryRepository
@Module
@InstallIn(SingletonComponent::class)
abstract class PersistenceBindingsModule {
@Binds
@Singleton
abstract fun bindHistoryRepository(implementation: RoomHistoryRepository): HistoryRepository
}
@Module
@InstallIn(SingletonComponent::class)
object PersistenceModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): LingjiDatabase =
Room.databaseBuilder(
context,
LingjiDatabase::class.java,
LingjiDatabase.NAME,
).build()
@Provides
fun provideCastingSessionDao(database: LingjiDatabase): CastingSessionDao =
database.castingSessionDao()
}
@@ -0,0 +1,28 @@
package net.opcapp.flash.data.history
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface CastingSessionDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(session: CastingSessionEntity): Long
@Query("SELECT * FROM casting_sessions WHERE id = :id LIMIT 1")
suspend fun findById(id: String): CastingSessionEntity?
@Query("SELECT EXISTS(SELECT 1 FROM casting_sessions WHERE id = :id)")
suspend fun exists(id: String): Boolean
@Query("SELECT COUNT(*) FROM casting_sessions")
fun observeCount(): Flow<Int>
@Query("SELECT * FROM casting_sessions ORDER BY created_at_epoch_millis DESC LIMIT 1")
fun observeLatest(): Flow<CastingSessionEntity?>
@Query("DELETE FROM casting_sessions WHERE id = :id")
suspend fun deleteById(id: String): Int
}
@@ -0,0 +1,28 @@
package net.opcapp.flash.data.history
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "casting_sessions",
indices = [Index(value = ["created_at_epoch_millis"])],
)
data class CastingSessionEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "created_at_epoch_millis") val createdAtEpochMillis: Long,
@ColumnInfo(name = "created_at_text") val createdAtText: String,
@ColumnInfo(name = "method_version") val methodVersion: String,
@ColumnInfo(name = "content_version") val contentVersion: String,
@ColumnInfo(name = "coin_convention") val coinConvention: String,
@ColumnInfo(name = "rounds_json") val roundsJson: String,
@ColumnInfo(name = "line_values") val lineValues: String,
@ColumnInfo(name = "primary_pattern") val primaryPattern: String,
@ColumnInfo(name = "moving_positions") val movingPositions: String,
@ColumnInfo(name = "transformed_pattern") val transformedPattern: String,
@ColumnInfo(name = "primary_hexagram_id") val primaryHexagramId: Int,
@ColumnInfo(name = "transformed_hexagram_id") val transformedHexagramId: Int,
@ColumnInfo(name = "question_text") val questionText: String?,
@ColumnInfo(name = "question_saved") val questionSaved: Boolean,
)
@@ -0,0 +1,72 @@
package net.opcapp.flash.data.history
import java.time.Instant
import net.opcapp.flash.domain.casting.CastRecordDto
import net.opcapp.flash.domain.casting.CastResult
internal object HistoryRecordCodec {
fun toEntity(
id: String,
result: CastResult,
questionText: String?,
saveQuestionText: Boolean,
): CastingSessionEntity {
val dto = CastRecordDto.fromDomain(result)
return CastingSessionEntity(
id = id,
createdAtEpochMillis = Instant.parse(dto.createdAt).toEpochMilli(),
createdAtText = dto.createdAt,
methodVersion = dto.methodVersion,
contentVersion = dto.contentVersion,
coinConvention = dto.coinConvention,
roundsJson = encodeRounds(dto.roundsBottomUp),
lineValues = dto.lineValuesBottomUp.joinToString(","),
primaryPattern = dto.primaryPatternBottomUp,
movingPositions = dto.movingLinePositions.joinToString(","),
transformedPattern = dto.transformedPatternBottomUp,
primaryHexagramId = dto.primaryHexagramId,
transformedHexagramId = dto.transformedHexagramId,
questionText = questionText?.takeIf { saveQuestionText },
questionSaved = saveQuestionText && questionText != null,
)
}
fun toDomain(entity: CastingSessionEntity): CastResult =
CastRecordDto(
schemaVersion = CastRecordDto.CURRENT_SCHEMA_VERSION,
methodVersion = entity.methodVersion,
coinConvention = entity.coinConvention,
roundsBottomUp = decodeRounds(entity.roundsJson),
lineValuesBottomUp = decodeIntegers(entity.lineValues),
primaryPatternBottomUp = entity.primaryPattern,
movingLinePositions = decodeIntegers(entity.movingPositions),
transformedPatternBottomUp = entity.transformedPattern,
primaryHexagramId = entity.primaryHexagramId,
transformedHexagramId = entity.transformedHexagramId,
contentVersion = entity.contentVersion,
createdAt = entity.createdAtText,
).toDomain()
private fun encodeRounds(rounds: List<List<Int>>): String =
rounds.joinToString(separator = ",", prefix = "[", postfix = "]") { round ->
round.joinToString(separator = ",", prefix = "[", postfix = "]")
}
private fun decodeRounds(value: String): List<List<Int>> {
require(value.startsWith("[[") && value.endsWith("]]")) {
"Stored rounds must be a JSON array"
}
val rounds = value.removePrefix("[").removeSuffix("]")
.split("],[")
.map { encodedRound ->
encodedRound.removePrefix("[").removeSuffix("]")
.split(",")
.map(String::toInt)
}
require(encodeRounds(rounds) == value) { "Stored rounds are not canonical" }
return rounds
}
private fun decodeIntegers(value: String): List<Int> =
if (value.isEmpty()) emptyList() else value.split(",").map(String::toInt)
}
@@ -0,0 +1,39 @@
package net.opcapp.flash.data.history
import kotlinx.coroutines.flow.Flow
import net.opcapp.flash.data.settings.HistorySavePolicy
import net.opcapp.flash.domain.casting.CastResult
data class SavedSessionSummary(
val id: String,
val createdAtEpochMillis: Long,
val primaryHexagramId: Int,
val transformedHexagramId: Int,
val movingLineCount: Int,
)
data class SavedCastingSession(
val id: String,
val questionText: String?,
val questionSaved: Boolean,
val result: CastResult,
)
interface HistoryRepository {
fun observeCount(): Flow<Int>
fun observeLatest(): Flow<SavedSessionSummary?>
suspend fun saveSession(
id: String,
result: CastResult,
questionText: String,
policy: HistorySavePolicy,
): SavedCastingSession
suspend fun findById(id: String): SavedCastingSession?
suspend fun exists(id: String): Boolean
suspend fun deleteById(id: String): Boolean
}
@@ -0,0 +1,17 @@
package net.opcapp.flash.data.history
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [CastingSessionEntity::class],
version = 1,
exportSchema = true,
)
abstract class LingjiDatabase : RoomDatabase() {
abstract fun castingSessionDao(): CastingSessionDao
companion object {
const val NAME = "lingji-history.db"
}
}
@@ -0,0 +1,65 @@
package net.opcapp.flash.data.history
import androidx.room.withTransaction
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import net.opcapp.flash.data.settings.HistorySavePolicy
import net.opcapp.flash.domain.casting.CastResult
@Singleton
class RoomHistoryRepository @Inject constructor(
private val database: LingjiDatabase,
private val dao: CastingSessionDao,
) : HistoryRepository {
override fun observeCount(): Flow<Int> = dao.observeCount()
override fun observeLatest(): Flow<SavedSessionSummary?> = dao.observeLatest().map { entity ->
entity?.toSummary()
}
override suspend fun saveSession(
id: String,
result: CastResult,
questionText: String,
policy: HistorySavePolicy,
): SavedCastingSession = database.withTransaction {
dao.insert(
HistoryRecordCodec.toEntity(
id = id,
result = result,
questionText = questionText,
saveQuestionText = policy.saveQuestionText,
),
)
checkNotNull(dao.findById(id)).toSavedSession()
}
override suspend fun findById(id: String): SavedCastingSession? =
dao.findById(id)?.toSavedSession()
override suspend fun exists(id: String): Boolean = dao.exists(id)
override suspend fun deleteById(id: String): Boolean = database.withTransaction {
dao.deleteById(id) > 0
}
private fun CastingSessionEntity.toSummary() = SavedSessionSummary(
id = id,
createdAtEpochMillis = createdAtEpochMillis,
primaryHexagramId = primaryHexagramId,
transformedHexagramId = transformedHexagramId,
movingLineCount = decodeMovingLineCount(movingPositions),
)
private fun CastingSessionEntity.toSavedSession() = SavedCastingSession(
id = id,
questionText = questionText,
questionSaved = questionSaved,
result = HistoryRecordCodec.toDomain(this),
)
private fun decodeMovingLineCount(value: String): Int =
if (value.isEmpty()) 0 else value.split(",").size
}
@@ -0,0 +1,8 @@
package net.opcapp.flash.data.settings
data class HistorySavePolicy(
val autoSaveHistory: Boolean = true,
val saveQuestionText: Boolean = true,
val saveExplanationContent: Boolean = true,
val saveActionNote: Boolean = true,
)
@@ -0,0 +1,73 @@
package net.opcapp.flash.data.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
private const val DATASTORE_NAME = "history-save-settings"
private val Context.historySettingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = DATASTORE_NAME,
)
@Singleton
class HistorySettingsRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val preferencesData: Flow<Preferences> = context.historySettingsDataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
val policy: Flow<HistorySavePolicy> = preferencesData.map { preferences ->
HistorySavePolicy(
autoSaveHistory = preferences[Keys.AutoSaveHistory] ?: true,
saveQuestionText = preferences[Keys.SaveQuestionText] ?: true,
saveExplanationContent = preferences[Keys.SaveExplanationContent] ?: true,
saveActionNote = preferences[Keys.SaveActionNote] ?: true,
)
}
val hasSeenMethodIntro: Flow<Boolean> = preferencesData.map { preferences ->
preferences[Keys.HasSeenMethodIntro] ?: false
}
suspend fun setHasSeenMethodIntro(seen: Boolean) = set(Keys.HasSeenMethodIntro, seen)
suspend fun setAutoSaveHistory(enabled: Boolean) = set(Keys.AutoSaveHistory, enabled)
suspend fun setSaveQuestionText(enabled: Boolean) = set(Keys.SaveQuestionText, enabled)
suspend fun setSaveExplanationContent(enabled: Boolean) =
set(Keys.SaveExplanationContent, enabled)
suspend fun setSaveActionNote(enabled: Boolean) = set(Keys.SaveActionNote, enabled)
private suspend fun set(key: Preferences.Key<Boolean>, enabled: Boolean) {
context.historySettingsDataStore.edit { preferences ->
preferences[key] = enabled
}
}
private object Keys {
val HasSeenMethodIntro = booleanPreferencesKey("has_seen_method_intro")
val AutoSaveHistory = booleanPreferencesKey("auto_save_history")
val SaveQuestionText = booleanPreferencesKey("save_question_text")
val SaveExplanationContent = booleanPreferencesKey("save_explanation_content")
val SaveActionNote = booleanPreferencesKey("save_action_note")
}
}
@@ -1,4 +1,4 @@
package brainwave.domain.casting
package net.opcapp.flash.domain.casting
data class CastComputation internal constructor(
val roundsBottomUp: List<CastRound>,
@@ -1,4 +1,4 @@
package brainwave.domain.casting
package net.opcapp.flash.domain.casting
data class CastRecordDto(
val schemaVersion: Int,
@@ -1,4 +1,4 @@
package brainwave.domain.casting
package net.opcapp.flash.domain.casting
enum class CoinSide(val score: Int) {
CHARACTER(2),
@@ -1,4 +1,4 @@
package brainwave.domain.casting
package net.opcapp.flash.domain.casting
object HexagramCatalog {
private enum class Trigram(val patternBottomUp: String) {
@@ -0,0 +1,325 @@
package net.opcapp.flash.feature.casting
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.Row
import androidx.compose.foundation.layout.Spacer
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.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag
import net.opcapp.flash.R
import net.opcapp.flash.domain.casting.CastRound
import net.opcapp.flash.domain.casting.CoinSide
import net.opcapp.flash.domain.casting.LineValue
@Composable
fun CastingScreen(
uiState: CastingUiState,
onSelectCoin: (Int, CoinSide) -> Unit,
onConfirmRound: () -> Unit,
onEditRound: (Int) -> Unit,
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_to_question))
}
Text(
text = stringResource(R.string.casting_step, uiState.currentRoundNumber),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.casting_title),
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = stringResource(R.string.coin_convention_visible),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LinearProgressIndicator(
progress = uiState.confirmedRounds.size / 6f,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = stringResource(
R.string.round_progress,
uiState.confirmedRounds.size,
6,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
CoinEntryCard(
currentRoundNumber = uiState.currentRoundNumber,
selections = uiState.currentCoins,
preview = uiState.currentRound,
onSelectCoin = onSelectCoin,
onConfirmRound = onConfirmRound,
)
if (uiState.confirmedRounds.isNotEmpty()) {
ConfirmedRoundsCard(
rounds = uiState.confirmedRounds,
onEditRound = onEditRound,
)
}
Spacer(Modifier.height(16.dp))
}
}
}
}
@Composable
private fun CoinEntryCard(
currentRoundNumber: Int,
selections: List<CoinSide?>,
preview: CastRound?,
onSelectCoin: (Int, CoinSide) -> Unit,
onConfirmRound: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(20.dp),
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.current_line_title, linePositionName(currentRoundNumber)),
style = MaterialTheme.typography.titleLarge,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(CastRound.COINS_PER_ROUND) { index ->
CoinSelector(
index = index,
selected = selections[index],
onSelected = { side -> onSelectCoin(index, side) },
modifier = Modifier.weight(1f),
)
}
}
if (preview != null) {
val lineValue = preview.lineValue
Text(
text = stringResource(
R.string.current_line_preview,
lineValue.score,
lineValue.displayName(),
if (lineValue.isMoving) {
stringResource(R.string.moving_line)
} else {
stringResource(R.string.static_line)
},
),
style = MaterialTheme.typography.bodyLarge,
color = if (lineValue.isMoving) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
} else {
Text(
text = stringResource(R.string.select_all_three_coins),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(
onClick = onConfirmRound,
enabled = preview != null,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.testTag("confirm_round"),
shape = RoundedCornerShape(16.dp),
) {
Text(
if (currentRoundNumber == 6) {
stringResource(R.string.confirm_and_finish)
} else {
stringResource(R.string.confirm_this_line)
},
)
}
}
}
}
@Composable
private fun CoinSelector(
index: Int,
selected: CoinSide?,
onSelected: (CoinSide) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.coin_index, index + 1),
style = MaterialTheme.typography.labelLarge,
)
CoinChoice(
text = stringResource(R.string.coin_character),
isSelected = selected == CoinSide.CHARACTER,
onClick = { onSelected(CoinSide.CHARACTER) },
testTag = "coin_${index}_character",
)
CoinChoice(
text = stringResource(R.string.coin_reverse),
isSelected = selected == CoinSide.REVERSE,
onClick = { onSelected(CoinSide.REVERSE) },
testTag = "coin_${index}_reverse",
)
}
}
@Composable
private fun CoinChoice(
text: String,
isSelected: Boolean,
onClick: () -> Unit,
testTag: String,
) {
OutlinedButton(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.semantics { selected = isSelected }
.testTag(testTag),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
},
contentColor = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
) {
Text(text)
}
}
@Composable
private fun ConfirmedRoundsCard(
rounds: List<CastRound>,
onEditRound: (Int) -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(20.dp),
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = stringResource(R.string.confirmed_lines_title),
style = MaterialTheme.typography.titleLarge,
)
rounds.forEachIndexed { index, round ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(
R.string.confirmed_line_summary,
linePositionName(index + 1),
round.lineValue.score,
round.lineValue.displayName(),
),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = { onEditRound(index) },
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.rerecord_from_line))
}
}
}
}
}
}
@Composable
private fun linePositionName(position: Int): String = when (position) {
1 -> stringResource(R.string.line_first)
2 -> stringResource(R.string.line_second)
3 -> stringResource(R.string.line_third)
4 -> stringResource(R.string.line_fourth)
5 -> stringResource(R.string.line_fifth)
6 -> stringResource(R.string.line_top)
else -> error("Line position must be 1..6")
}
@Composable
private fun LineValue.displayName(): String = when (this) {
LineValue.OLD_YIN -> stringResource(R.string.old_yin)
LineValue.YOUNG_YANG -> stringResource(R.string.young_yang)
LineValue.YOUNG_YIN -> stringResource(R.string.young_yin)
LineValue.OLD_YANG -> stringResource(R.string.old_yang)
}
@@ -0,0 +1,373 @@
package net.opcapp.flash.feature.casting
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.Instant
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
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
import net.opcapp.flash.domain.casting.CastEngine
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
data object Saving : HistorySaveStatus
data class Saved(
val sessionId: String,
val questionSaved: Boolean,
) : HistorySaveStatus
data object NotSaved : HistorySaveStatus
data object Deleting : HistorySaveStatus
data class Failed(val operation: Operation) : HistorySaveStatus {
enum class Operation {
SAVE,
DELETE,
}
}
}
data class CastingUiState(
val question: String = "",
val confirmedRounds: List<CastRound> = emptyList(),
val currentCoins: List<CoinSide?> = List(CastRound.COINS_PER_ROUND) { null },
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)
val currentRound: CastRound?
get() = currentCoins.filterNotNull()
.takeIf { it.size == CastRound.COINS_PER_ROUND }
?.let(CastRound::of)
}
@HiltViewModel
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(
question = savedStateHandle[Keys.Question] ?: "",
confirmedRounds = restoredRounds,
currentCoins = decodeCurrentCoins(savedStateHandle[Keys.CurrentCoins]),
result = restoredResult,
sessionId = savedStateHandle[Keys.SessionId],
saveStatus = if (restoredResult == null) {
HistorySaveStatus.Draft
} else {
HistorySaveStatus.Saving
},
primaryContent = restoredContent.primary,
transformedContent = restoredContent.transformed,
contentError = restoredContent.error,
showSpecialUsage = restoredContent.showSpecialUsage,
),
)
val uiState: StateFlow<CastingUiState> = _uiState.asStateFlow()
private var persistenceJob: Job? = null
init {
if (restoredResult != null) reconcileRestoredResult()
}
fun startNewSession() {
persistenceJob?.cancel()
savedStateHandle.remove<String>(Keys.CreatedAt)
savedStateHandle.remove<String>(Keys.SessionId)
saveQuestion("")
saveRounds(emptyList())
saveCurrentCoins(List(CastRound.COINS_PER_ROUND) { null })
_uiState.value = CastingUiState()
}
fun updateQuestion(question: String) {
saveQuestion(question)
_uiState.value = _uiState.value.copy(question = question)
}
fun selectCoin(index: Int, side: CoinSide) {
require(index in 0 until CastRound.COINS_PER_ROUND)
check(_uiState.value.result == null) { "A sealed casting session cannot be edited" }
val updated = _uiState.value.currentCoins.toMutableList().apply { this[index] = side }
saveCurrentCoins(updated)
_uiState.value = _uiState.value.copy(currentCoins = updated)
}
fun confirmCurrentRound(): Boolean {
val state = _uiState.value
check(state.result == null) { "A sealed casting session cannot be confirmed again" }
val round = state.currentRound ?: return false
val updatedRounds = state.confirmedRounds + round
saveRounds(updatedRounds)
saveCurrentCoins(List(CastRound.COINS_PER_ROUND) { null })
if (updatedRounds.size < 6) {
_uiState.value = state.copy(
confirmedRounds = updatedRounds,
currentCoins = List(CastRound.COINS_PER_ROUND) { null },
)
return false
}
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(
confirmedRounds = updatedRounds,
currentCoins = List(CastRound.COINS_PER_ROUND) { null },
result = result,
sessionId = sessionId,
saveStatus = HistorySaveStatus.Saving,
primaryContent = content.primary,
transformedContent = content.transformed,
contentError = content.error,
showSpecialUsage = content.showSpecialUsage,
)
persistResult(autoOnly = true)
return true
}
fun editFrom(roundIndex: Int) {
val state = _uiState.value
check(state.result == null) { "A sealed casting session cannot be edited" }
require(roundIndex in state.confirmedRounds.indices)
val roundToEdit = state.confirmedRounds[roundIndex]
val retainedRounds = state.confirmedRounds.take(roundIndex)
saveRounds(retainedRounds)
saveCurrentCoins(roundToEdit.coins)
_uiState.value = state.copy(
confirmedRounds = retainedRounds,
currentCoins = roundToEdit.coins,
)
}
fun saveManually() = persistResult(autoOnly = false)
fun retryLastPersistenceOperation() {
when ((_uiState.value.saveStatus as? HistorySaveStatus.Failed)?.operation) {
HistorySaveStatus.Failed.Operation.SAVE -> persistResult(autoOnly = false)
HistorySaveStatus.Failed.Operation.DELETE -> deleteSavedSession()
null -> Unit
}
}
fun deleteSavedSession() {
val sessionId = _uiState.value.sessionId
?: (_uiState.value.saveStatus as? HistorySaveStatus.Saved)?.sessionId
if (sessionId == null) {
persistenceJob?.cancel()
_uiState.value = _uiState.value.copy(saveStatus = HistorySaveStatus.NotSaved)
return
}
persistenceJob?.cancel()
_uiState.value = _uiState.value.copy(saveStatus = HistorySaveStatus.Deleting)
persistenceJob = viewModelScope.launch {
try {
historyRepository.deleteById(sessionId)
_uiState.value = _uiState.value.copy(saveStatus = HistorySaveStatus.NotSaved)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
_uiState.value = _uiState.value.copy(
saveStatus = HistorySaveStatus.Failed(
HistorySaveStatus.Failed.Operation.DELETE,
),
)
}
}
}
private fun reconcileRestoredResult() {
val state = _uiState.value
val sessionId = state.sessionId ?: return
persistenceJob = viewModelScope.launch {
try {
applySaveSuccess(
historyRepository.findById(sessionId) ?: saveState(state, autoOnly = true),
)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
applySaveFailure()
}
}
}
private fun persistResult(autoOnly: Boolean) {
val state = _uiState.value
if (state.result == null || state.sessionId == null) return
persistenceJob?.cancel()
_uiState.value = state.copy(saveStatus = HistorySaveStatus.Saving)
persistenceJob = viewModelScope.launch {
try {
applySaveSuccess(saveState(state, autoOnly))
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
applySaveFailure()
}
}
}
private suspend fun saveState(
state: CastingUiState,
autoOnly: Boolean,
): SavedCastingSession? {
val result = state.result ?: return null
val sessionId = state.sessionId ?: return null
val policy = settingsRepository.policy.first()
if (autoOnly && !policy.autoSaveHistory) return null
return historyRepository.saveSession(
id = sessionId,
result = result,
questionText = state.question.trim(),
policy = policy,
)
}
private fun applySaveSuccess(savedSession: SavedCastingSession?) {
_uiState.value = _uiState.value.copy(
saveStatus = if (savedSession == null) {
HistorySaveStatus.NotSaved
} else {
HistorySaveStatus.Saved(
sessionId = savedSession.id,
questionSaved = savedSession.questionSaved,
)
},
)
}
private fun applySaveFailure() {
_uiState.value = _uiState.value.copy(
saveStatus = HistorySaveStatus.Failed(
HistorySaveStatus.Failed.Operation.SAVE,
),
)
}
private fun saveQuestion(question: String) {
savedStateHandle[Keys.Question] = question
}
private fun saveRounds(rounds: List<CastRound>) {
savedStateHandle[Keys.Rounds] = ArrayList(
rounds.flatMap { round -> round.coins.map(CoinSide::score) },
)
}
private fun saveCurrentCoins(coins: List<CoinSide?>) {
savedStateHandle[Keys.CurrentCoins] = ArrayList(coins.map { it?.score ?: 0 })
}
private fun createResult(rounds: List<CastRound>, createdAt: String): CastResult =
CastResult.record(
computation = CastEngine.cast(rounds),
metadata = CastMetadata(
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)
private fun decodeCurrentCoins(scores: ArrayList<Int>?): List<CoinSide?> {
val restored = scores.orEmpty().take(CastRound.COINS_PER_ROUND).map { score ->
if (score == 0) null else CoinSide.fromScore(score)
}
return restored + List(CastRound.COINS_PER_ROUND - restored.size) { null }
}
private object Keys {
const val Question = "casting_question"
const val Rounds = "casting_round_scores"
const val CurrentCoins = "casting_current_coin_scores"
const val CreatedAt = "casting_created_at"
const val SessionId = "casting_session_id"
}
}
@@ -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,
)
}
}
}
}
@@ -0,0 +1,223 @@
package net.opcapp.flash.feature.home
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.Button
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import java.util.Date
import net.opcapp.flash.R
import net.opcapp.flash.core.model.HexagramNames
@Composable
fun HomeScreen(
uiState: HomeUiState,
onStartCasting: () -> Unit,
onOpenSettings: () -> Unit,
onOpenMethodIntro: () -> Unit,
onOpenContentSources: () -> Unit,
modifier: Modifier = Modifier,
) {
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 = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.home_kicker),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(24.dp))
Surface(
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(18.dp),
) {
Text(
text = stringResource(R.string.seal_character),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(horizontal = 22.dp, vertical = 16.dp),
)
}
Spacer(Modifier.height(20.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.displaySmall,
)
Text(
text = stringResource(R.string.home_lede),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(Modifier.height(32.dp))
Button(
onClick = onStartCasting,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
) {
Text(stringResource(R.string.start_casting))
}
Spacer(Modifier.height(20.dp))
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 = stringResource(R.string.local_storage_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = stringResource(R.string.local_storage_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (uiState.savePolicy.autoSaveHistory) {
stringResource(R.string.auto_save_on)
} else {
stringResource(R.string.auto_save_off)
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
TextButton(
onClick = onOpenSettings,
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.open_save_settings))
}
TextButton(
onClick = onOpenMethodIntro,
modifier = Modifier.height(48.dp),
) {
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))
HistorySummary(uiState)
}
}
}
}
@Composable
private fun HistorySummary(uiState: HomeUiState) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(20.dp),
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.local_records_title),
style = MaterialTheme.typography.titleLarge,
)
if (uiState.latest == null) {
Text(
text = stringResource(R.string.no_saved_records),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val latest = checkNotNull(uiState.latest)
val context = LocalContext.current
val recordedAt = DateFormat.getMediumDateFormat(context)
.format(Date(latest.createdAtEpochMillis))
Text(
text = stringResource(R.string.saved_record_count, uiState.savedCount),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.latest_record_date, recordedAt),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (latest.movingLineCount == 0) {
stringResource(
R.string.latest_static_hexagram_summary,
HexagramNames.nameFor(latest.primaryHexagramId),
latest.primaryHexagramId,
)
} else {
stringResource(
R.string.latest_hexagram_summary,
HexagramNames.nameFor(latest.primaryHexagramId),
latest.primaryHexagramId,
HexagramNames.nameFor(latest.transformedHexagramId),
latest.transformedHexagramId,
)
},
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Start,
)
Text(
text = if (latest.movingLineCount == 0) {
stringResource(R.string.latest_no_moving_lines)
} else {
stringResource(R.string.latest_moving_line_count, latest.movingLineCount)
},
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.latest_explanation_pending),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,64 @@
package net.opcapp.flash.feature.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
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
import net.opcapp.flash.data.settings.HistorySettingsRepository
data class HomeUiState(
val savedCount: Int = 0,
val latest: SavedSessionSummary? = null,
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(),
settingsRepository.policy,
settingsRepository.hasSeenMethodIntro,
) { savedCount, latest, savePolicy, hasSeenMethodIntro ->
HomeUiState(
savedCount = savedCount,
latest = latest,
savePolicy = savePolicy,
hasSeenMethodIntro = hasSeenMethodIntro,
isReady = true,
contentManifest = contentManifest,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HomeUiState(),
)
fun markMethodIntroSeen() {
viewModelScope.launch {
settingsRepository.setHasSeenMethodIntro(true)
}
}
}
@@ -0,0 +1,103 @@
package net.opcapp.flash.feature.onboarding
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.Spacer
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.Button
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.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import net.opcapp.flash.R
@Composable
fun MethodIntroScreen(
showBack: Boolean,
onContinue: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(enabled = showBack, 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),
) {
if (showBack) {
TextButton(
onClick = onBack,
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.back))
}
} else {
Spacer(Modifier.height(12.dp))
}
Text(
text = stringResource(R.string.method_intro_kicker),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.method_intro_title),
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = stringResource(R.string.method_intro_lede),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
MethodPoint(stringResource(R.string.method_intro_you_cast))
MethodPoint(stringResource(R.string.method_intro_coin_rule))
MethodPoint(stringResource(R.string.method_intro_not_decision))
Spacer(Modifier.height(8.dp))
Button(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.testTag("method_intro_continue"),
shape = RoundedCornerShape(16.dp),
) {
Text(stringResource(R.string.method_intro_start))
}
}
}
}
}
@Composable
private fun MethodPoint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
)
}
@@ -0,0 +1,133 @@
package net.opcapp.flash.feature.question
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.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.platform.testTag
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import net.opcapp.flash.R
@Composable
fun QuestionScreen(
question: String,
onQuestionChanged: (String) -> Unit,
onContinue: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onBack)
val count = QuestionValidation.characterCount(question)
val isValid = QuestionValidation.isValid(question)
val isTooLong = count > QuestionValidation.MAX_CHARACTERS
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.question_step),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.question_title),
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = stringResource(R.string.question_guidance),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = question,
onValueChange = onQuestionChanged,
modifier = Modifier
.fillMaxWidth()
.testTag("question_input"),
label = { Text(stringResource(R.string.question_label)) },
supportingText = {
Text(
text = if (isTooLong) {
stringResource(R.string.question_too_long)
} else {
stringResource(R.string.question_privacy_helper)
},
)
},
isError = isTooLong,
minLines = 4,
maxLines = 8,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
keyboardType = KeyboardType.Text,
),
)
Text(
text = stringResource(
R.string.character_count,
count,
QuestionValidation.MAX_CHARACTERS,
),
style = MaterialTheme.typography.bodyMedium,
color = if (isTooLong) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.align(Alignment.End),
)
Button(
onClick = onContinue,
enabled = isValid,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
) {
Text(stringResource(R.string.start_coin_entry))
}
}
}
}
}
@@ -0,0 +1,12 @@
package net.opcapp.flash.feature.question
object QuestionValidation {
const val MAX_CHARACTERS = 200
fun characterCount(text: String): Int = text.codePointCount(0, text.length)
fun isValid(text: String): Boolean {
val count = characterCount(text.trim())
return count in 1..MAX_CHARACTERS
}
}
@@ -0,0 +1,572 @@
package net.opcapp.flash.feature.result
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.size
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.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
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
import net.opcapp.flash.domain.casting.Polarity
import net.opcapp.flash.feature.casting.CastingUiState
import net.opcapp.flash.feature.casting.HistorySaveStatus
@Composable
fun CastResultScreen(
uiState: CastingUiState,
onBackHome: () -> Unit,
onStartAgain: () -> Unit,
onSave: () -> Unit,
onDelete: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onBackHome)
val result = uiState.result
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 = onBackHome,
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.back_home))
}
if (result == null) {
Text(
text = stringResource(R.string.result_missing),
style = MaterialTheme.typography.headlineMedium,
)
} else {
ResultContent(
result = result,
saveStatus = uiState.saveStatus,
primaryContent = uiState.primaryContent,
transformedContent = uiState.transformedContent,
contentError = uiState.contentError,
showSpecialUsage = uiState.showSpecialUsage,
onSave = onSave,
onDelete = onDelete,
onRetry = onRetry,
onStartAgain = onStartAgain,
)
}
Spacer(Modifier.height(16.dp))
}
}
}
}
@Composable
private fun ResultContent(
result: CastResult,
saveStatus: HistorySaveStatus,
primaryContent: HexagramContent?,
transformedContent: HexagramContent?,
contentError: String?,
showSpecialUsage: Boolean,
onSave: () -> Unit,
onDelete: () -> Unit,
onRetry: () -> Unit,
onStartAgain: () -> Unit,
) {
val primaryName = HexagramNames.nameFor(result.primaryHexagramId)
val transformedName = HexagramNames.nameFor(result.transformedHexagramId)
Text(
text = stringResource(R.string.result_kicker),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = if (result.movingLinePositions.isEmpty()) {
stringResource(
R.string.static_result_title,
primaryName,
result.primaryHexagramId.value,
)
} else {
stringResource(
R.string.moving_result_title,
primaryName,
result.primaryHexagramId.value,
transformedName,
result.transformedHexagramId.value,
)
},
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = movingLinesText(result.movingLinePositions),
style = MaterialTheme.typography.titleLarge,
color = if (result.movingLinePositions.isEmpty()) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.primary
},
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
HexagramDiagram(
linesBottomUp = result.primaryPatternBottomUp.linesBottomUp,
movingPositions = result.movingLinePositions,
description = stringResource(
R.string.primary_hexagram_accessibility,
primaryName,
result.primaryHexagramId.value,
),
)
if (result.movingLinePositions.isNotEmpty()) {
HexagramDiagram(
linesBottomUp = result.transformedPatternBottomUp.linesBottomUp,
movingPositions = emptyList(),
description = stringResource(
R.string.transformed_hexagram_accessibility,
transformedName,
result.transformedHexagramId.value,
),
)
}
}
RawLinesCard(result.lineValuesBottomUp)
HexagramTextsCard(
result = result,
primaryContent = primaryContent,
transformedContent = transformedContent,
contentError = contentError,
showSpecialUsage = showSpecialUsage,
)
SaveStatusCard(
status = saveStatus,
onSave = onSave,
onDelete = onDelete,
onRetry = onRetry,
)
Text(
text = stringResource(R.string.content_plain_draft_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = onStartAgain,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
) {
Text(stringResource(R.string.start_again))
}
}
@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(
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 = stringResource(R.string.raw_lines_title),
style = MaterialTheme.typography.titleLarge,
)
lines.forEachIndexed { index, line ->
Text(
text = stringResource(
R.string.raw_line_summary,
linePositionName(index + 1),
line.score,
lineValueName(line),
if (line.isMoving) {
stringResource(R.string.moving_line)
} else {
stringResource(R.string.static_line)
},
),
style = MaterialTheme.typography.bodyLarge,
color = if (line.isMoving) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
}
@Composable
private fun SaveStatusCard(
status: HistorySaveStatus,
onSave: () -> Unit,
onDelete: () -> Unit,
onRetry: () -> Unit,
) {
var confirmDelete by remember { mutableStateOf(false) }
LaunchedEffect(status) {
if (status !is HistorySaveStatus.Saved) {
confirmDelete = false
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(20.dp),
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (status is HistorySaveStatus.Saving || status is HistorySaveStatus.Deleting) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
Text(
text = saveStatusText(status),
style = MaterialTheme.typography.titleLarge,
)
}
Text(
text = stringResource(R.string.save_status_privacy),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when (status) {
HistorySaveStatus.NotSaved -> OutlinedButton(
onClick = onSave,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
) {
Text(stringResource(R.string.save_this_time))
}
is HistorySaveStatus.Saved -> if (confirmDelete) {
Text(
text = stringResource(R.string.delete_current_record_title),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.delete_current_record_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(
onClick = onDelete,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.testTag("confirm_delete_record"),
) {
Text(
text = stringResource(R.string.delete_record),
color = MaterialTheme.colorScheme.error,
)
}
TextButton(
onClick = { confirmDelete = false },
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.cancel))
}
} else {
TextButton(
onClick = { confirmDelete = true },
modifier = Modifier
.height(48.dp)
.testTag("do_not_save_this_time"),
) {
Text(
text = stringResource(R.string.do_not_save_this_time),
color = MaterialTheme.colorScheme.error,
)
}
}
is HistorySaveStatus.Failed -> OutlinedButton(
onClick = onRetry,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
) {
Text(stringResource(R.string.retry))
}
else -> Unit
}
}
}
}
@Composable
private fun saveStatusText(status: HistorySaveStatus): String = when (status) {
HistorySaveStatus.Draft -> stringResource(R.string.save_status_preparing)
HistorySaveStatus.Saving -> stringResource(R.string.save_status_saving)
HistorySaveStatus.Deleting -> stringResource(R.string.save_status_deleting)
HistorySaveStatus.NotSaved -> stringResource(R.string.save_status_not_saved)
is HistorySaveStatus.Saved -> if (status.questionSaved) {
stringResource(R.string.save_status_saved_complete)
} else {
stringResource(R.string.save_status_saved_without_question)
}
is HistorySaveStatus.Failed -> when (status.operation) {
HistorySaveStatus.Failed.Operation.SAVE -> stringResource(R.string.save_status_failed)
HistorySaveStatus.Failed.Operation.DELETE -> stringResource(R.string.delete_status_failed)
}
}
@Composable
private fun movingLinesText(positions: List<Int>): String =
if (positions.isEmpty()) {
stringResource(R.string.no_moving_lines)
} else {
var positionNames = ""
for (position in positions) {
if (positionNames.isNotEmpty()) positionNames += "、"
positionNames += linePositionName(position)
}
stringResource(
R.string.moving_lines_summary,
positionNames,
)
}
@Composable
private fun linePositionName(position: Int): String = when (position) {
1 -> stringResource(R.string.line_first)
2 -> stringResource(R.string.line_second)
3 -> stringResource(R.string.line_third)
4 -> stringResource(R.string.line_fourth)
5 -> stringResource(R.string.line_fifth)
6 -> stringResource(R.string.line_top)
else -> error("Line position must be 1..6")
}
@Composable
private fun lineValueName(line: LineValue): String = when (line) {
LineValue.OLD_YIN -> stringResource(R.string.old_yin)
LineValue.YOUNG_YANG -> stringResource(R.string.young_yang)
LineValue.YOUNG_YIN -> stringResource(R.string.young_yin)
LineValue.OLD_YANG -> stringResource(R.string.old_yang)
}
@Composable
private fun HexagramDiagram(
linesBottomUp: List<Polarity>,
movingPositions: List<Int>,
description: String,
) {
val ink = MaterialTheme.colorScheme.onBackground
val movingInk = MaterialTheme.colorScheme.primary
val lineWidth = with(LocalDensity.current) { 58.dp.toPx() }
val gap = with(LocalDensity.current) { 12.dp.toPx() }
val strokeWidth = with(LocalDensity.current) { 5.dp.toPx() }
val markerRadius = with(LocalDensity.current) { 4.dp.toPx() }
Canvas(
modifier = Modifier
.size(width = 100.dp, height = 136.dp)
.semantics { contentDescription = description },
) {
val verticalStep = size.height / 6f
linesBottomUp.forEachIndexed { index, polarity ->
val y = size.height - verticalStep * (index + 0.5f)
val left = (size.width - lineWidth) / 2f
val right = left + lineWidth
val color = if (index + 1 in movingPositions) movingInk else ink
if (polarity == Polarity.YANG) {
drawLine(
color = color,
start = Offset(left, y),
end = Offset(right, y),
strokeWidth = strokeWidth,
cap = StrokeCap.Square,
)
} else {
val center = size.width / 2f
drawLine(
color = color,
start = Offset(left, y),
end = Offset(center - gap / 2f, y),
strokeWidth = strokeWidth,
cap = StrokeCap.Square,
)
drawLine(
color = color,
start = Offset(center + gap / 2f, y),
end = Offset(right, y),
strokeWidth = strokeWidth,
cap = StrokeCap.Square,
)
}
if (index + 1 in movingPositions) {
drawCircle(
color = movingInk,
radius = markerRadius,
center = Offset(right + gap, y),
)
}
}
}
}
@@ -0,0 +1,185 @@
package net.opcapp.flash.feature.settings
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.Row
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.selection.toggleable
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.Switch
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.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import net.opcapp.flash.R
import net.opcapp.flash.data.settings.HistorySavePolicy
@Composable
fun HistorySettingsScreen(
policy: HistorySavePolicy,
onAutoSaveChanged: (Boolean) -> Unit,
onSaveQuestionChanged: (Boolean) -> Unit,
onSaveExplanationChanged: (Boolean) -> Unit,
onSaveActionChanged: (Boolean) -> Unit,
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.save_settings_title),
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = stringResource(R.string.save_settings_description),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(20.dp),
) {
Column {
SettingsToggle(
title = stringResource(R.string.auto_save_complete_record),
description = stringResource(R.string.auto_save_complete_record_description),
checked = policy.autoSaveHistory,
testTag = "auto_save_history",
onCheckedChange = onAutoSaveChanged,
)
SettingsToggle(
title = stringResource(R.string.save_question_text),
description = stringResource(R.string.save_question_text_description),
checked = policy.saveQuestionText,
enabled = policy.autoSaveHistory,
onCheckedChange = onSaveQuestionChanged,
)
SettingsToggle(
title = stringResource(R.string.save_explanation_content),
description = stringResource(R.string.save_explanation_content_description),
checked = policy.saveExplanationContent,
enabled = policy.autoSaveHistory,
onCheckedChange = onSaveExplanationChanged,
)
SettingsToggle(
title = stringResource(R.string.save_action_note),
description = stringResource(R.string.save_action_note_description),
checked = policy.saveActionNote,
enabled = policy.autoSaveHistory,
onCheckedChange = onSaveActionChanged,
)
}
}
if (!policy.autoSaveHistory) {
Text(
text = stringResource(R.string.disabled_settings_explanation),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = stringResource(R.string.no_cloud_backup_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun SettingsToggle(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true,
testTag: String? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (testTag == null) Modifier else Modifier.testTag(testTag))
.then(
if (enabled) {
Modifier.toggleable(
value = checked,
role = Role.Switch,
onValueChange = onCheckedChange,
)
} else {
Modifier.semantics { disabled() }
},
)
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled,
)
}
}
@@ -0,0 +1,43 @@
package net.opcapp.flash.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.opcapp.flash.data.settings.HistorySavePolicy
import net.opcapp.flash.data.settings.HistorySettingsRepository
@HiltViewModel
class HistorySettingsViewModel @Inject constructor(
private val repository: HistorySettingsRepository,
) : ViewModel() {
val policy: StateFlow<HistorySavePolicy> = repository.policy.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HistorySavePolicy(),
)
fun setAutoSaveHistory(enabled: Boolean) = update {
repository.setAutoSaveHistory(enabled)
}
fun setSaveQuestionText(enabled: Boolean) = update {
repository.setSaveQuestionText(enabled)
}
fun setSaveExplanationContent(enabled: Boolean) = update {
repository.setSaveExplanationContent(enabled)
}
fun setSaveActionNote(enabled: Boolean) = update {
repository.setSaveActionNote(enabled)
}
private fun update(block: suspend () -> Unit) {
viewModelScope.launch { block() }
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Lingji" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:statusBarColor">#1E1B18</item>
<item name="android:navigationBarColor">#1E1B18</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
+129
View File
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">灵机</string>
<string name="home_kicker">三枚铜币 · 六爻由下而上</string>
<string name="home_lede">你投币,应用记录;结果用于整理想法,不替你预测或决定。</string>
<string name="seal_character">易</string>
<string name="start_casting">开始一问</string>
<string name="local_storage_title">你的数据留在本机</string>
<string name="local_storage_notice">起卦与历史默认保存在本机,不主动上传。只有你选择 AI 解读时,本次所需内容才会发送。</string>
<string name="auto_save_on">自动保存完整记录:已开启</string>
<string name="auto_save_off">自动保存完整记录:已关闭</string>
<string name="open_save_settings">管理保存设置</string>
<string name="open_method_intro">方法说明</string>
<string name="local_records_title">你的观照</string>
<string name="no_saved_records">还没有已完成的记录。未完成的问题和投币不会进入长期历史。</string>
<string name="saved_record_count">已保存 %1$d 次起卦</string>
<string name="latest_record_date">最近一次 · %1$s</string>
<string name="latest_hexagram_summary">%1$s %2$d → %3$s %4$d</string>
<string name="latest_static_hexagram_summary">%1$s %2$d</string>
<string name="latest_moving_line_count">%1$d 个动爻</string>
<string name="latest_no_moving_lines">无动爻</string>
<string name="latest_explanation_pending">尚未解读</string>
<string name="method_intro_kicker">第一次起卦前</string>
<string name="method_intro_title">把一个犹豫,安静地放在这里。</string>
<string name="method_intro_lede">应用只记录你亲手投出的铜币,并在本机算出本卦、之卦和动爻。</string>
<string name="method_intro_you_cast">你投币,应用记录;AI 不参与起卦。</string>
<string name="method_intro_coin_rule">字为 2,背为 3;第一次是初爻,由下而上。</string>
<string name="method_intro_not_decision">结果用于整理想法,不替你做决定。</string>
<string name="method_intro_start">开始</string>
<string name="back">返回</string>
<string name="back_home">返回首页</string>
<string name="back_to_question">返回修改问题</string>
<string name="question_step">起念 · 第一步</string>
<string name="question_title">此刻,想慢下来看看什么?</string>
<string name="question_guidance">尽量写成一个具体、可由自己采取行动的问题。这里不是预测,也不替你做决定。</string>
<string name="question_label">此刻想问的事</string>
<string name="question_privacy_helper">1–200 字。完成后按保存设置留在本机,不会因为保存而上传。</string>
<string name="question_too_long">超过 200 字,请缩短后继续。</string>
<string name="character_count">%1$d / %2$d 字</string>
<string name="start_coin_entry">开始录入铜币</string>
<string name="casting_step">投币 · 第 %1$d 爻</string>
<string name="casting_title">依次录入三枚铜币</string>
<string name="coin_convention_visible">固定计值:字 2,背 3。请真实投掷后逐枚选择,应用不代投。</string>
<string name="round_progress">已确认 %1$d / %2$d 爻</string>
<string name="current_line_title">正在录入:%1$s</string>
<string name="coin_index">第 %1$d 枚</string>
<string name="coin_character">字 2</string>
<string name="coin_reverse">背 3</string>
<string name="current_line_preview">和值 %1$d · %2$s · %3$s</string>
<string name="select_all_three_coins">选完三枚后,将在这里显示本爻的和值与动静。</string>
<string name="confirm_this_line">确认本爻</string>
<string name="confirm_and_finish">确认上爻并成卦</string>
<string name="confirmed_lines_title">已确认(由下而上)</string>
<string name="confirmed_line_summary">%1$s · %2$d %3$s</string>
<string name="rerecord_from_line">从此重录</string>
<string name="moving_line">动爻</string>
<string name="static_line">静爻</string>
<string name="line_first">初爻</string>
<string name="line_second">二爻</string>
<string name="line_third">三爻</string>
<string name="line_fourth">四爻</string>
<string name="line_fifth">五爻</string>
<string name="line_top">上爻</string>
<string name="old_yin">老阴</string>
<string name="young_yang">少阳</string>
<string name="young_yin">少阴</string>
<string name="old_yang">老阳</string>
<string name="save_settings_title">本机保存设置</string>
<string name="save_settings_description">这些开关只控制应用私有存储,不构成上传或 AI 授权。</string>
<string name="auto_save_complete_record">自动保存完整记录</string>
<string name="auto_save_complete_record_description">第六爻确认后立即创建本机会话。</string>
<string name="save_question_text">保存问题原文</string>
<string name="save_question_text_description">关闭后仍保存可复核卦象,但不保存你写的问题。</string>
<string name="save_explanation_content">保存解读全文</string>
<string name="save_explanation_content_description">解读功能接入后,按此选择关联到同一会话。</string>
<string name="save_action_note">保存行动记录</string>
<string name="save_action_note_description">行动记录功能接入后,按此选择保存在本机。</string>
<string name="disabled_settings_explanation">总开关关闭时,三个内容选择保留但暂不参与自动保存;重新开启后恢复。</string>
<string name="no_cloud_backup_notice">历史数据库已排除系统自动备份和设备迁移;当前版本不提供账号、导出或云同步。</string>
<string name="result_kicker">本地计算完成</string>
<string name="moving_result_title">%1$s %2$d → %3$s %4$d</string>
<string name="static_result_title">%1$s %2$d</string>
<string name="moving_lines_summary">动爻:%1$s</string>
<string name="no_moving_lines">无动爻,本卦不变</string>
<string name="primary_hexagram_accessibility">本卦 %1$s,第 %2$d 卦</string>
<string name="transformed_hexagram_accessibility">之卦 %1$s,第 %2$d 卦</string>
<string name="raw_lines_title">六爻复核(由下而上)</string>
<string name="raw_line_summary">%1$s · %2$d %3$s · %4$s</string>
<string name="save_status_privacy">保存仅发生在应用私有本机数据库中,不会触发网络请求。</string>
<string name="save_status_preparing">正在准备记录</string>
<string name="save_status_saving">正在保存到本机</string>
<string name="save_status_deleting">正在删除本机记录</string>
<string name="save_status_not_saved">本次尚未保存</string>
<string name="save_status_saved_complete">已自动保存完整记录到本机</string>
<string name="save_status_saved_without_question">卦象已保存到本机,问题原文未保存</string>
<string name="save_status_failed">保存失败,原始结果仍可查看</string>
<string name="delete_status_failed">删除失败,请重试</string>
<string name="save_this_time">保存本次</string>
<string name="do_not_save_this_time">本次不保存</string>
<string name="retry">重试</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>
<string name="delete_current_record_message">将删除已经自动保存的问题和卦象快照,不改变全局保存设置。</string>
<string name="delete_record">删除记录</string>
<string name="cancel">取消</string>
</resources>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Lingji" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:statusBarColor">#F5F0E6</item>
<item name="android:navigationBarColor">#F5F0E6</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</full-backup-content>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,14 @@
package net.opcapp.flash.core.model
import org.junit.Assert.assertEquals
import org.junit.Test
class HexagramNamesTest {
@Test
fun knownKingWenNamesAreStable() {
assertEquals("乾", HexagramNames.nameFor(1))
assertEquals("坤", HexagramNames.nameFor(2))
assertEquals("复", HexagramNames.nameFor(24))
assertEquals("未济", HexagramNames.nameFor(64))
}
}
@@ -1,13 +1,23 @@
package brainwave.data.content
package net.opcapp.flash.data.content
import brainwave.core.model.HexagramContent
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"))
}
}
}
@@ -1,6 +1,6 @@
package brainwave.data.content
package net.opcapp.flash.data.content
import brainwave.core.model.HexagramContent
import net.opcapp.flash.core.model.HexagramContent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
@@ -0,0 +1,85 @@
package net.opcapp.flash.data.history
import net.opcapp.flash.domain.casting.CastEngine
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.LineValue
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test
class HistoryRecordCodecTest {
private val result = CastResult.record(
computation = CastEngine.cast(
listOf(
CastRound.fromLineValue(LineValue.OLD_YANG),
CastRound.fromLineValue(LineValue.YOUNG_YIN),
CastRound.fromLineValue(LineValue.YOUNG_YIN),
CastRound.fromLineValue(LineValue.YOUNG_YIN),
CastRound.fromLineValue(LineValue.YOUNG_YIN),
CastRound.fromLineValue(LineValue.YOUNG_YIN),
),
),
metadata = CastMetadata(
contentVersion = "hexagram-names-v1",
createdAt = "2026-08-08T00:00:00Z",
),
)
@Test
fun entityRoundTripRecomputesAndPreservesTheCast() {
val entity = HistoryRecordCodec.toEntity(
id = "fixture-session",
result = result,
questionText = "我该先验证哪个假设?",
saveQuestionText = true,
)
val restored = HistoryRecordCodec.toDomain(entity)
assertEquals(result, restored)
assertEquals("我该先验证哪个假设?", entity.questionText)
}
@Test
fun questionIsNotCopiedWhenItsPolicyIsOff() {
val entity = HistoryRecordCodec.toEntity(
id = "fixture-session",
result = result,
questionText = "敏感问题夹具",
saveQuestionText = false,
)
assertNull(entity.questionText)
assertEquals(false, entity.questionSaved)
}
@Test
fun missingQuestionIsNeverMarkedAsSaved() {
val entity = HistoryRecordCodec.toEntity(
id = "fixture-session",
result = result,
questionText = null,
saveQuestionText = true,
)
assertNull(entity.questionText)
assertEquals(false, entity.questionSaved)
}
@Test
fun tamperedDerivedValuesAreRejectedOnRead() {
val entity = HistoryRecordCodec.toEntity(
id = "fixture-session",
result = result,
questionText = null,
saveQuestionText = false,
).copy(primaryHexagramId = 1)
assertThrows(IllegalArgumentException::class.java) {
HistoryRecordCodec.toDomain(entity)
}
}
}
@@ -1,4 +1,4 @@
package brainwave.domain.casting
package net.opcapp.flash.domain.casting
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -0,0 +1,29 @@
package net.opcapp.flash.feature.question
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class QuestionValidationTest {
@Test
fun blankQuestionIsRejected() {
assertFalse(QuestionValidation.isValid(" "))
}
@Test
fun oneToTwoHundredCharactersAreAccepted() {
assertTrue(QuestionValidation.isValid("我该先验证哪个假设?"))
assertTrue(QuestionValidation.isValid("问".repeat(200)))
}
@Test
fun moreThanTwoHundredCharactersAreRejected() {
assertFalse(QuestionValidation.isValid("问".repeat(201)))
}
@Test
fun supplementaryUnicodeCodePointCountsAsOneCharacter() {
assertEquals(1, QuestionValidation.characterCount("𠀀"))
}
}
+14 -1
View File
@@ -2,6 +2,10 @@ import org.gradle.api.tasks.Exec
plugins {
base
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.hilt) apply false
}
fun registerNodeVerificationTask(
@@ -57,11 +61,19 @@ val verifyDependencyLicenseManifest = registerNodeVerificationTask(
script = "scripts/verify-dependency-licenses.mjs",
)
val verifyLocalDataBoundary = registerNodeVerificationTask(
name = "verifyLocalDataBoundary",
description = "Locks down offline permissions and excludes local records from backup and transfer.",
script = "scripts/verify-local-data-boundary.mjs",
)
tasks.register("verifyLocal") {
group = LifecycleBasePlugin.VERIFICATION_GROUP
description = "Runs every environment-independent local quality gate."
dependsOn(
":app:test",
":app:lintDebug",
":app:testDebugUnitTest",
":app:assembleDebug",
":app:verifyDependencyLicenses",
verifyDocs,
scanSecrets,
@@ -70,6 +82,7 @@ tasks.register("verifyLocal") {
verifyContentContract,
spotlessCheck,
verifyDependencyLicenseManifest,
verifyLocalDataBoundary,
)
}
+147 -5
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"reviewedAt": "2026-08-04",
"scopeNote": "Current JVM runtime/test graph and direct build entry points; Android dependencies are not configured yet.",
"reviewedAt": "2026-08-19",
"scopeNote": "Android debug runtime, debug unit-test runtime, and debug instrumentation-test runtime graphs, plus direct build entry points. Release notices still require a release-graph review.",
"components": [
{
"id": "gradle-wrapper",
@@ -11,6 +11,14 @@
"license": "Apache-2.0",
"source": "https://github.com/gradle/gradle/blob/v8.2.0/LICENSE"
},
{
"id": "android-gradle-plugin",
"coordinates": null,
"version": "8.2.0",
"scope": "build",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/tools/base/+/studio-2022.2.1-patch4/LICENSE"
},
{
"id": "kotlin-gradle-plugin",
"coordinates": null,
@@ -19,6 +27,54 @@
"license": "Apache-2.0",
"source": "https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt"
},
{
"id": "hilt-gradle-plugin",
"coordinates": null,
"version": "2.48.1",
"scope": "build",
"license": "Apache-2.0",
"source": "https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt"
},
{
"id": "compose-bom",
"coordinates": "androidx.compose:compose-bom:2023.10.01",
"version": "2023.10.01",
"scope": "runtime-constraints",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
},
{
"id": "navigation-compose",
"coordinates": "androidx.navigation:navigation-compose:2.7.5",
"version": "2.7.5",
"scope": "runtime",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
},
{
"id": "room-runtime",
"coordinates": "androidx.room:room-runtime:2.6.1",
"version": "2.6.1",
"scope": "runtime",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
},
{
"id": "datastore-preferences",
"coordinates": "androidx.datastore:datastore-preferences:1.0.0",
"version": "1.0.0",
"scope": "runtime",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
},
{
"id": "hilt-android",
"coordinates": "com.google.dagger:hilt-android:2.48.1",
"version": "2.48.1",
"scope": "runtime",
"license": "Apache-2.0",
"source": "https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt"
},
{
"id": "kotlin-stdlib",
"coordinates": "org.jetbrains.kotlin:kotlin-stdlib:1.9.20",
@@ -29,11 +85,35 @@
},
{
"id": "jetbrains-annotations",
"coordinates": "org.jetbrains:annotations:13.0",
"version": "13.0",
"coordinates": "org.jetbrains:annotations:23.0.0",
"version": "23.0.0",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://repo1.maven.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.pom"
"source": "https://repo1.maven.org/maven2/org/jetbrains/annotations/23.0.0/annotations-23.0.0.pom"
},
{
"id": "jsr305",
"coordinates": "com.google.code.findbugs:jsr305:3.0.2",
"version": "3.0.2",
"scope": "runtime-transitive",
"license": "BSD-3-Clause",
"source": "https://repo1.maven.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.pom"
},
{
"id": "guava-listenablefuture",
"coordinates": "com.google.guava:listenablefuture:1.0",
"version": "1.0",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://repo1.maven.org/maven2/com/google/guava/listenablefuture/1.0/listenablefuture-1.0.pom"
},
{
"id": "javax-inject",
"coordinates": "javax.inject:javax.inject:1",
"version": "1",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://repo1.maven.org/maven2/javax/inject/javax.inject/1/javax.inject-1.pom"
},
{
"id": "junit4",
@@ -50,6 +130,68 @@
"scope": "test-transitive",
"license": "BSD-3-Clause",
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
},
{
"id": "hamcrest-library",
"coordinates": "org.hamcrest:hamcrest-library:1.3",
"version": "1.3",
"scope": "androidTest-transitive",
"license": "BSD-3-Clause",
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
},
{
"id": "hamcrest-integration",
"coordinates": "org.hamcrest:hamcrest-integration:1.3",
"version": "1.3",
"scope": "androidTest-transitive",
"license": "BSD-3-Clause",
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt"
},
{
"id": "gson",
"coordinates": "com.google.code.gson:gson:2.9.0",
"version": "2.9.0",
"scope": "androidTest-transitive",
"license": "Apache-2.0",
"source": "https://github.com/google/gson/blob/gson-parent-2.9.0/LICENSE"
},
{
"id": "javawriter",
"coordinates": "com.squareup:javawriter:2.1.1",
"version": "2.1.1",
"scope": "androidTest-transitive",
"license": "Apache-2.0",
"source": "https://github.com/square/javawriter/blob/javawriter-2.1.1/LICENSE.txt"
}
],
"groupPolicies": [
{
"id": "androidx-runtime-policy",
"groupPrefix": "androidx",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt"
},
{
"id": "dagger-runtime-policy",
"groupPrefix": "com.google.dagger",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt"
},
{
"id": "kotlin-runtime-policy",
"groupPrefix": "org.jetbrains.kotlin",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt"
},
{
"id": "kotlinx-runtime-policy",
"groupPrefix": "org.jetbrains.kotlinx",
"scope": "runtime-transitive",
"license": "Apache-2.0",
"source": "https://github.com/Kotlin/kotlinx.coroutines/blob/1.7.1/LICENSE.txt"
}
]
}
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
/** 灵机自撰白话草稿。按文王序 1–64;每项为 [卦辞, 初, 二, 三, 四, 五, 上]。 */
export const lingjiPlain = [
["开始即通达,利于守持正道。", "龙还潜藏着,先不要施展。", "龙出现在田野,利于去见有识之人。", "君子整日健进,夜里仍警惕;虽有危难,可以无灾。", "或跳进深渊,可以无灾。", "龙飞在天上,利于去见有识之人。", "龙飞得过高,会有悔恨。"],
["开始即通达,利于像母马那样守持正道。君子有所前往,先会迷路,随后才得到依靠。利于西南得到同类,东北失去同类。安于守正,吉。", "踩到霜,坚冰就要来了。", "正直方正而广大,不必演习,没有不利。", "蕴含文采,可以守正。或跟从王事,不求自己成功,却能有结果。", "扎紧口袋,无灾也无称誉。", "黄色下裳,大为吉祥。", "龙在郊野争斗,血流成青黄色。"],
["开始即通达,利于守正。不要急着前往,利于立诸侯。", "徘徊不进,利于安居守正,利于立诸侯。", "困顿难行,乘马盘旋。不是强盗,是来求婚。女子守正不嫁,十年才许嫁。", "追鹿却没有虞人向导,只是陷入林中。君子察觉危险,不如放弃;再往前会有憾恨。", "乘马盘旋,去求婚,前往吉,没有不利。", "屯积膏泽。小事守正吉,大事守正凶。", "乘马盘旋,血泪涟涟。"],
["亨通。我不是要去求童蒙,是童蒙来求我。初次卜问则告知;再三乱问,就不告知。利于守正。", "启发蒙昧,利于用刑罚纠正人,用桎梏约束;若一再用刑,就有憾恨。", "包容蒙昧,吉。接纳妇人,吉。孩子能自立,家庭就不再受困。", "不要娶这个女子,她见了财礼就失了自身。无利可图。", "困于蒙昧,憾恨。", "童蒙,吉。", "打击蒙昧。不利于做强盗,利于抵御强盗。"],
["有所等待,有诚信。光辉亨通,守正吉。利于涉大河。", "在郊野等待,利于用恒常的方法,无灾。", "在沙滩等待,小有议论,最终吉。", "在泥中等待,招来贼寇。", "在血中等待,从洞穴里出来。", "在酒食中等待,守正吉。", "进入洞穴,有不请自来的三位客人;尊敬他们,最终吉。"],
["有诚信,在窒塞中警惕,中正则吉,最终凶。利于见大人,不利于涉大河。", "不永久纠缠事务,小有议论,最终吉。", "败于讼事,回家逃避。邑人三百户,无灾。", "食用旧德,守正有危,最终吉。或跟从王事,没有成功。", "不能胜诉,仍复归于常理,改变态度,安静守正则吉。", "讼事,元吉。", "或赐给大带,一天之内被剥夺三次。"],
["贞正。大人吉,无灾。兵众,正而不邪才可用。", "出兵要有纪律;否则,凶。", "在队伍中,无灾,王三次赐命。", "师或载尸而还,凶。", "师左次而退,无灾。", "田里有禽,利于执言,无灾。长子帅师,弟子载尸,贞凶。", "大君有命,开国承家。小人不可重用。"],
["吉。原筮,元永贞,无灾。不安宁的人前来,后夫凶。", "有诚信,比之无灾。有诚信充满瓦缶,终来有别的吉。", "比之从内,贞吉。", "比之匪人。", "在外相比,贞吉。", "显比。王用三驱,失前禽。邑人不诫,吉。", "比之无首,凶。"],
["亨通。密云不雨,自我西郊。", "复归于自己的道路,何其咎?吉。", "牵复,吉。", "舆说辐。夫妻反目。", "有孚,血去惕出,无灾。", "有孚挛如,富以其邻。", "既雨既处,尚德载。妇贞厉。月几望,君子征凶。"],
["踩踏老虎尾巴,不咬人,亨通。", "素履往,无灾。", "履道坦坦,幽人贞吉。", "眇能视,跛能履,履虎尾,咥人,凶。武人为于大君。", "履虎尾,愬愬,终吉。", "夬履,贞厉。", "视履考祥,其旋元吉。"],
["小往大来,吉,亨通。", "拔茅连根,以其类,征吉。", "包荒,用冯河,不遐遗。朋亡,得尚于中行。", "无平不陂,无往不复。艰贞无灾。勿恤其孚,于食有福。", "翩翩,不富以其邻,不戒以孚。", "帝乙归妹,以祉元吉。", "城复于隍,勿用师。自邑告命,贞吝。"],
["否之匪人,不利于君子守正。大的往,小的来。", "拔茅连根,以其类,守正吉,亨通。", "包容奉承。小人吉,大人否塞。亨通。", "包藏羞辱。", "有天命,无灾。同类依附而得福。", "休止否塞,大人吉。心中念着将亡将亡,才能系于苞桑。", "倾覆否塞。先否塞,后喜悦。"],
["同人于野,亨通。利于涉大河,利于君子守正。", "同人于门,无灾。", "同人于宗,吝。", "伏戎于莽,升其高陵,三年不兴。", "乘其墉,弗克攻,吉。", "同人,先号咷而后笑。大师克相遇。", "同人于郊,无悔。"],
["元亨。", "无交害,匪咎,艰则无灾。", "大车以载,有攸往,无灾。", "公用亨于天子。小人弗克。", "匪其彭,无灾。", "厥孚交如,威如,吉。", "自天佑之,吉无不利。"],
["亨通。君子有终。", "谦谦君子,用涉大川,吉。", "鸣谦,贞吉。", "劳谦,君子有终,吉。", "无不利,撝谦。", "不富以其邻,利用侵伐,无不利。", "鸣谦,利用行师,征邑国。"],
["利于建侯行师。", "鸣豫,凶。", "介于石,不终日,贞吉。", "盱豫,悔。迟则有悔。", "由豫,大有得。勿疑,朋盍簪。", "贞疾,恒不死。", "冥豫,成有渝,无灾。"],
["元亨,利贞,无灾。", "官有渝,贞吉。出门交有功。", "系小子,失丈夫。", "系丈夫,失小子。随有求得,利居贞。", "随有获,贞凶。有孚在道,以明,何咎。", "孚于嘉,吉。", "拘系之,乃从维之。王用亨于西山。"],
["元亨,利涉大川。先甲三日,后甲三日。", "干父之蛊,有子,考无咎。厉,终吉。", "干母之蛊,不可贞。", "干父之蛊,小有悔,无大咎。", "裕父之蛊,往见吝。", "干父之蛊,用誉。", "不事王侯,高尚其事。"],
["元亨,利贞。至于八月有凶。", "咸临,贞吉。", "咸临,吉,无不利。", "甘临,无攸利。既忧之,无灾。", "至临,无灾。", "知临,大君之宜,吉。", "敦临,吉,无灾。"],
["盥而不荐,有孚颙若。", "童观,小人无咎,君子吝。", "窥观,利女贞。", "观我生,进退。", "观国之光,利用宾于王。", "观我生,君子无咎。", "观其生,君子无咎。"],
["亨通。利用狱。", "屦校灭趾,无灾。", "噬肤灭鼻,无灾。", "噬腊肉,遇毒。小吝,无灾。", "噬干胏,得金矢。利艰贞,吉。", "噬干肉,得黄金。贞厉,无灾。", "何校灭耳,凶。"],
["亨通。小利有攸往。", "贲其趾,舍车而徒。", "贲其须。", "贲如濡如,永贞吉。", "贲如皤如,白马翰如。匪寇婚媾。", "贲于丘园,束帛戋戋。吝,终吉。", "白贲,无灾。"],
["不利于有所前往。", "剥床以足,蔑贞,凶。", "剥床以辨,蔑贞,凶。", "剥之,无灾。", "剥床以肤,凶。", "贯鱼,以宫人宠,无不利。", "硕果不食。君子得舆,小人剥庐。"],
["亨通。出入没有疾病,朋友来也无灾。一来一回按那条路走,七天会回复。利于有所前往。", "回复得还不远,没有大的悔恨,大为吉祥。", "美好地回复,吉。", "频繁回复,有危难,无灾。", "走在行列中间,独自回复。", "敦厚地回复,没有悔恨。", "迷路却不回复,凶,有灾祸。用此出兵,终有大败,连累国君,凶;到十年还不能出征。"],
["元亨,利贞。其匪正有眚,不利有攸往。", "无妄,往吉。", "不耕获,不菑畲,则利有攸往。", "无妄之灾。或系之牛,行人之得,邑人之灾。", "可贞,无灾。", "无妄之疾,勿药有喜。", "无妄,行有眚,无攸利。"],
["利贞。不家食,吉。利涉大川。", "有厉,利已。", "舆说輹。", "良马逐,利艰贞。日闲舆卫,利有攸往。", "童牛之牿,元吉。", "豮豕之牙,吉。", "何天之衢,亨。"],
["贞吉。观颐,自求口实。", "舍尔灵龟,观我朵颐,凶。", "颠颐,拂经,于丘颐,征凶。", "拂颐,贞凶。十年勿用,无攸利。", "颠颐,吉。虎视眈眈,其欲逐逐,无灾。", "拂经,居贞吉,不可涉大川。", "由颐,厉吉,利涉大川。"],
["栋桡。利有攸往,亨。", "藉用白茅,无灾。", "枯杨生稊,老夫得其女妻,无不利。", "栋桡,凶。", "栋隆,吉。有它吝。", "枯杨生华,老妇得其士夫,无咎无誉。", "过涉灭顶,凶,无灾。"],
["有孚,维心亨,行有尚。", "习坎,入于坎窞,凶。", "坎有险,求小得。", "来之坎坎,险且枕,入于坎窞,勿用。", "樽酒簋贰,用缶,纳约自牖,终无灾。", "坎不盈,祗既平,无灾。", "系用徽纆,置于丛棘,三岁不得,凶。"],
["利贞,亨。畜牝牛,吉。", "履错然,敬之,无灾。", "黄离,元吉。", "日昃之离,不鼓缶而歌,则大耋之嗟,凶。", "突如其来如,焚如,死如,弃如。", "出涕沱若,戚嗟若,吉。", "王用出征,有嘉折首,获匪其丑,无灾。"],
["亨通。利贞。取女吉。", "咸其拇。", "咸其腓,凶。居吉。", "咸其股,执其随,往吝。", "贞吉,悔亡。憧憧往来,朋从尔思。", "咸其脢,无悔。", "咸其辅颊舌。"],
["亨通。无灾。利于守正。利于有所前往。", "深求恒久,守正则凶,无所利。", "悔恨消失。", "不能恒久其德,或许会承受羞辱,守正有憾。", "田里没有禽兽。", "恒久其德,守正。妇人吉,男子凶。", "振动不安于恒,凶。"],
["亨通。小利于守正。", "遁走只剩尾巴,有危,不要有所前往。", "用黄牛皮捆住,没有人能解脱。", "被牵绊着退避,有疾病和危难。畜养臣妾,吉。", "喜好退避,君子吉,小人否塞。", "嘉美地退避,守正吉。", "充裕地退避,没有不利。"],
["利于守正。", "壮在脚趾,出征凶,有诚信。", "贞吉。", "小人用壮,君子用罔,贞厉。公羊触藩,缠住了角。", "贞吉,悔恨消失。藩篱没被破坏,壮在大车的辐上。", "在田里丧失羊,无悔。", "公羊触藩,不能退不能进,无攸利。艰难则吉。"],
["康侯用锡马蕃庶,白天三次接见。", "晋如摧如,贞吉。罔孚,裕无灾。", "晋如愁如,贞吉。受兹介福,于其王母。", "众允,悔亡。", "晋如鼫鼠,贞厉。", "悔亡,失得勿恤。往吉,无不利。", "晋其角,维用伐邑,厉吉,无灾,贞吝。"],
["利于艰难中守正。", "明夷于飞,垂其翼。君子于行,三日不食。有攸往,主人有言。", "明夷,夷于左股,用拯马壮,吉。", "明夷于南狩,得其大首,不可疾贞。", "入于左腹,获明夷之心,于出门庭。", "箕子之明夷,利贞。", "不明晦。初登于天,后入于地。"],
["利于女子守正。", "闲有家,悔亡。", "无攸遂,在中馈,贞吉。", "家人嗃嗃,悔厉吉;妇子嘻嘻,终吝。", "富家,大吉。", "王假有家,勿恤,吉。", "有孚威如,终吉。"],
["小事吉。", "悔亡。丧马勿逐,自复。见恶人,无灾。", "遇主于巷,无灾。", "见舆曳,其牛掣,其人天且劓。无初有终。", "睽孤,遇元夫,交孚,厉无灾。", "悔亡。厥宗噬肤,往何咎。", "睽孤,见豕负涂,载鬼一车。先张之弧,后说之弧。匪寇婚媾。往遇雨则吉。"],
["利西南,不利东北。利见大人。贞吉。", "往蹇,来誉。", "王臣蹇蹇,匪躬之故。", "往蹇,来反。", "往蹇,来连。", "大蹇,朋来。", "往蹇,来硕,吉。利见大人。"],
["利西南。无所往,其来复吉。有攸往,夙吉。", "无灾。", "田获三狐,得黄矢,贞吉。", "负且乘,致寇至,贞吝。", "解而拇,朋至斯孚。", "君子维有解,吉。有孚于小人。", "公用射隼于高墉之上,获之,无不利。"],
["有孚,元吉,无灾,可贞,利有攸往。曷之用?二簋可用享。", "已事遄往,无灾。酌损之。", "利贞。征凶。弗损,益之。", "三人行则损一人,一人行则得其友。", "损其疾,使遄有喜,无灾。", "或益之十朋之龟,弗克违,元吉。", "弗损,益之,无灾。贞吉。利有攸往,得臣无家。"],
["利有攸往。利涉大川。", "利用为大作,元吉,无灾。", "或益之十朋之龟,弗克违。永贞吉。王用享于帝,吉。", "益之用凶事,无灾。有孚中行,告公用圭。", "中行,告公从,利用为依迁国。", "有孚惠心,勿问元吉。有孚惠我德。", "莫益之,或击之,立心勿恒,凶。"],
["扬于王庭。孚号有厉。告自邑,不利即戎,利有攸往。", "壮于前趾,往不胜为咎。", "惕号,莫夜有戎,勿恤。", "壮于頄,有凶。君子夬夬,独行遇雨,若濡有愠,无灾。", "臀无肤,其行次且。牵羊悔亡,闻言不信。", "苋陆夬夬,中行无灾。", "无号,终有凶。"],
["女壮,勿用取女。", "系于金柅,贞吉。有攸往,见凶。羸豕孚蹢躅。", "包有鱼,无灾,不利宾。", "臀无肤,其行次且,厉,无大咎。", "包无鱼,起凶。", "以杞包瓜,含章,有陨自天。", "姤其角,吝,无灾。"],
["亨通。王假有庙。利见大人,亨,利贞。用大牲吉。利有攸往。", "有孚不终,乃乱乃萃。若号,一握为笑,勿恤,往无灾。", "引吉,无灾。孚乃利用禴。", "萃如嗟如,无攸利。往无灾,小吝。", "大吉,无灾。", "萃有位,无灾。匪孚,元永贞,悔亡。", "赍咨涕洟,无灾。"],
["元亨。用见大人,勿恤。南征吉。", "允升,大吉。", "孚乃利用禴,无灾。", "升虚邑。", "王用亨于岐山,吉,无灾。", "贞吉,升阶。", "冥升,利于不息之贞。"],
["亨通。贞,大人吉,无灾。有言不信。", "臀困于株木,入于幽谷,三岁不觌。", "困于酒食,朱绂方来,利用享祀。征凶,无灾。", "困于石,据于蒺藜。入于其宫,不见其妻,凶。", "来徐徐,困于金车,吝,有终。", "劓刖,困于赤绂。乃徐有说,利用祭祀。", "困于葛藟,于臲卼。曰动悔有悔,征吉。"],
["改邑不改井,无丧无得。往来井井。汔至,亦未繘井,羸其瓶,凶。", "井泥不食,旧井无禽。", "井谷射鲋,瓮敝漏。", "井渫不食,为我心恻。可用汲,王明,并受其福。", "井甃,无灾。", "井冽,寒泉食。", "井收勿幕,有孚元吉。"],
["己日乃孚。元亨,利贞,悔亡。", "巩用黄牛之革。", "己日乃革之,征吉,无灾。", "征凶,贞厉。革言三就,有孚。", "悔亡。有孚改命,吉。", "大人虎变,未占有孚。", "君子豹变,小人革面。征凶,居贞吉。"],
["元吉,亨通。", "鼎颠趾,利出否。得妾以其子,无灾。", "鼎有实。我仇有疾,不我能即,吉。", "鼎耳革,其行塞,雉膏不食。方雨亏悔,终吉。", "鼎折足,覆公餗,其形渥,凶。", "鼎黄耳金铉,利贞。", "鼎玉铉,大吉,无不利。"],
["亨通。震来虩虩,笑言哑哑。震惊百里,不丧匕鬯。", "震来虩虩,后笑言哑哑,吉。", "震来厉,亿丧贝。跻于九陵,勿逐,七日得。", "震苏苏,震行无眚。", "震遂泥。", "震往来厉,亿无丧,有事。", "震索索,视矍矍,征凶。震不于其躬,于其邻,无灾。婚媾有言。"],
["艮其背,不获其身;行其庭,不见其人,无灾。", "艮其趾,无灾,利永贞。", "艮其腓,不拯其随,其心不快。", "艮其限,列其夤,厉薰心。", "艮其身,无灾。", "艮其辅,言有序,悔亡。", "敦艮,吉。"],
["女归吉。利贞。", "鸿渐于干,小子厉,有言,无灾。", "鸿渐于磐,饮食衎衎,吉。", "鸿渐于陆,夫征不复,妇孕不育,凶。利御寇。", "鸿渐于木,或得其桷,无灾。", "鸿渐于陵,妇三岁不孕,终莫之胜,吉。", "鸿渐于陆,其羽可用为仪,吉。"],
["征凶,无攸利。", "归妹以娣,跛能履,征吉。", "眇能视,利幽人之贞。", "归妹以须,反归以娣。", "归妹愆期,迟归有时。", "帝乙归妹,其君之袂不如其娣之袂良。月几望,吉。", "女承筐无实,士刲羊无血,无攸利。"],
["亨通。王假之。勿忧,宜日中。", "遇其配主,虽旬无灾,往有尚。", "丰其蔀,日中见斗。往得疑疾,有孚发若,吉。", "丰其沛,日中见沫。折其右肱,无灾。", "丰其蔀,日中见斗。遇其夷主,吉。", "来章,有庆誉,吉。", "丰其屋,蔀其家,窥其户,阒其无人,三岁不觌,凶。"],
["小亨。旅贞吉。", "旅琐琐,斯其所取灾。", "旅即次,怀其资,得童仆贞。", "旅焚其次,丧其童仆,贞厉。", "旅于处,得其资斧,我心不快。", "射雉,一矢亡,终以誉命。", "鸟焚其巢,旅人先笑后号咷。丧牛于易,凶。"],
["小亨。利有攸往。利见大人。", "进退,利武人之贞。", "巽在床下,用史巫纷若,吉,无灾。", "频巽,吝。", "悔亡,田获三品。", "贞吉,悔亡,无不利。无初有终。先庚三日,后庚三日,吉。", "巽在床下,丧其资斧,贞凶。"],
["亨通。利贞。", "和兑,吉。", "孚兑,吉,悔亡。", "来兑,凶。", "商兑未宁,介疾有喜。", "孚于剥,有厉。", "引兑。"],
["亨通。王假有庙。利涉大川,利贞。", "用拯马壮,吉。", "涣奔其机,悔亡。", "涣其躬,无悔。", "涣其群,元吉。涣有丘,匪夷所思。", "涣汗其大号,涣王居,无灾。", "涣其血,去逖出,无灾。"],
["亨通。苦节不可贞。", "不出户庭,无灾。", "不出门庭,凶。", "不节若,则嗟若,无灾。", "安节,亨。", "甘节,吉。往有尚。", "苦节,贞凶,悔亡。"],
["豚鱼吉。利涉大川,利贞。", "虞吉,有它不燕。", "鸣鹤在阴,其子和之。我有好爵,吾与尔靡之。", "得敌,或鼓或罢,或泣或歌。", "月几望,马匹亡,无灾。", "有孚挛如,无灾。", "翰音登于天,贞凶。"],
["亨通。利贞。可小事,不可大事。飞鸟遗之音,不宜上,宜下,大吉。", "飞鸟以凶。", "过其祖,遇其妣。不及其君,遇其臣,无灾。", "弗过防之,从或戕之,凶。", "无灾。弗过遇之。往厉必戒,勿用永贞。", "密云不雨,自我西郊。公弋取彼在穴。", "弗遇过之,飞鸟离之,凶。是谓灾眚。"],
["亨通。小利贞。初吉终乱。", "曳其轮,濡其尾,无灾。", "妇丧其茀,勿逐,七日得。", "高宗伐鬼方,三年克之,小人勿用。", "繻有衣袽,终日戒。", "东邻杀牛,不如西邻之禴祭,实受其福。", "濡其首,厉。"],
["亨通。小狐汔济,濡其尾,无攸利。", "濡其尾,吝。", "曳其轮,贞吉。", "未济,征凶,利涉大川。", "贞吉,悔亡。震用伐鬼方,三年有赏于大国。", "贞吉,无悔。君子之光,有孚,吉。", "有孚于饮酒,无灾。濡其首,有孚失是。"],
];
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -1,14 +1,14 @@
# Brainwave 项目文档
> 文档状态:方案基线
> 最后核验:2026-08-04
> 当前阶段:P-1 v0.3 待视觉复核;P1 纯 Kotlin 领域核心已完成;P0 仓库门禁与 P2 内容契约已部分完成;可发布 Android 壳仍等待正式身份与 `minSdk` 决策
> 最后核验:2026-08-19
> 当前阶段:正式产品名“灵机”;P0/P1 已完成;P3 离线主流程(方法说明、起念、手录、卦名/动爻结果)已可走通;P4 仅完成会话级本机保存与设置,问卦簿与本地解释未开始;P2 已接入维基文库《易经》原文与项目自撰白话草稿,待内容负责人审校;P-1 v0.3 待视觉复核;P5 未开始
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
## 项目一句话
Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个人反思工具:用户亲手投掷并录入六次结果,应用在本地确定本卦、之卦和动爻;用户主动点击「解」后,本地内容或 AI 才把既有结果翻译成现代语言,并收束到一项低风险、可撤销的现实行动。
灵机(仓库代号 Brainwave)是一个以《易经》三枚铜币法为文化背景的 Android 个人反思工具:用户亲手投掷并录入六次结果,应用在本地确定本卦、之卦和动爻;用户主动点击「解」后,本地内容或 AI 才把既有结果翻译成现代语言,并收束到一项低风险、可撤销的现实行动。
## 文档地图
@@ -22,6 +22,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
| [移动端交互原型](prototype.md) | 评审流程、视觉或开始 Compose 页面前 | 可点击原型、截图入口、确认清单和 Android 映射 |
| [系统架构](architecture.md) | 新增包、依赖、数据源或网络能力时 | 分层、依赖方向、运行时数据流 |
| [数据与内容](data-content.md) | 修改卦库、历史记录或内容来源时 | 数据契约、授权、隐私和迁移规则 |
| [内容审校](content-review.md) | 审校卦辞来源或准备发布时 | 来源、异文和签核状态 |
| [AI 解释与安全](ai-safety.md) | 修改提示词、模型调用或解释结果时 | AI 调用门、输入输出契约和安全边界 |
| [质量门禁](quality-gates.md) | 实现、评审、发布前 | 自动化验证、需求追踪和完成定义 |
| [依赖与许可证](dependency-licenses.md) | 新增/升级依赖或准备分发时 | 当前解析依赖、SPDX 与权威许可来源 |
@@ -69,6 +70,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
已确认:
- 目标平台为原生 Android。
- 正式产品名为“灵机”,namespace/application ID 为 `net.opcapp.flash`,`minSdk=26`。
- 使用 Kotlin、Jetpack Compose 和单 Activity 架构。
- 起卦完全在本地完成,AI 不得参与或更改起卦结果。
- 用户真实投币并录入;MVP 不提供随机起卦按钮。
@@ -89,4 +91,4 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
- 测试命令、构建方式或发布门禁改变;
- 出现可能再次发生的缺陷。
文档链接、需求编号和验证命令将随工程骨架一起接入 CI 检查。当前尚无 Gradle 工程,不能声称任何构建或测试已经通过。
文档链接、需求编号和验证命令由 `verifyLocal` 机械检查。授权真机存在时另跑设备仪器测试;未运行的门禁不得写成已经通过。
+6 -4
View File
@@ -39,11 +39,11 @@ Android 官方建议新应用采用清晰的 UI/数据分层、单向数据流
工程初始化后建议采用:
```text
app/src/main/java/<package>/
app/src/main/java/net/opcapp/flash/
├── app/
│ ├── BrainwaveApplication.kt
│ ├── LingjiApplication.kt
│ ├── MainActivity.kt
│ └── BrainwaveNavHost.kt
│ └── LingjiApp.kt
├── core/
│ ├── model/ # 跨层不可变领域模型
│ ├── designsystem/ # 主题、字体、间距、卦象 Canvas
@@ -69,6 +69,8 @@ app/src/main/java/<package>/
测试按相同包结构镜像放入 `src/test` 和 `src/androidTest`。
当前已落地的包:`app/`、`core/model`、`core/designsystem`、`domain/casting`、`data/content`(assets 解析器与测试 fake)、`data/history`、`data/settings`、`feature/onboarding`、`feature/home`、`feature/question`、`feature/casting`、`feature/result`、`feature/settings`、`feature/content`。尚未创建 `feature/history`、`feature/explanation`、`domain/explanation` 和 `data/ai`。首页不为问卦簿或「解」生成空占位。
## 4. 依赖方向
允许:
@@ -180,7 +182,7 @@ App → HTTPS backend → model provider
- 禁止直接引入完整“算命 SDK”或无法审计的卦象计算库。
- Compose 依赖使用 BOM 对齐版本。
- `compileSdk`/`targetSdk` 使用实现时最新稳定且满足商店要求的版本,不在方案文档硬编码会迅速过期的数值。
- `minSdk` 默认提案为 26,最终值见[决策记录](decisions.md#未决问题)。
- `minSdk=26` 已由 [ADR-014](decisions.md#adr-014正式-android-身份与最低版本) 确认;提高最低版本必须以依赖要求或覆盖率数据另立决策。
## 11. 多模块化触发条件
+34
View File
@@ -0,0 +1,34 @@
# 内容审校记录
> 状态:P2 数据包已生成,待内容负责人确认可再分发
> 内容版本:`zh-Hans-2026.1`
## 已接受来源
| 来源 ID | 文本 | 许可 | 说明 |
|---|---|---|---|
| `wikisource-zhouyi-jing-zh-Hans` | 卦辞、爻辞、乾用九、坤用六 | 公版(Wikimedia PD-old) | 维基文库《周易》仅《易经》;不含彖、象、文言等十翼。导入脚本:`scripts/import-zhouyi-wikisource.mjs` |
| `lingji-plain-zh-Hans-2026.1` | 本地白话 | 项目自撰草稿 | `content/plain/lingji-plain.mjs`;审校完成前不得当作已授权现代译本 |
未采用南怀瑾、傅佩荣或其他仍受版权保护的现代译注。
## 工程入口
- 原始导入:`content/raw/zhouyi-wikisource-jing.json`
- 发布包:`content/packages/hexagram-content.json`
- 构建:`node scripts/build-hexagram-package.mjs`
- 再导入需本机 HTTP 代理(默认 `http://127.0.0.1:1080`)访问 `zh.wikisource.org`
## 已知异文
维基文库复卦初九作「不复远,无袛悔」,与常见通行本「不远复,无祇悔」不同。本包按维基文库底本收录,不在导入时改字。审校时可决定是否改从通行本并提升 `contentVersion`。
## 审校清单
- [ ] 64 卦卦辞、384 爻与通行《易经》对读,记录有意保留的异文
- [ ] 确认不含十翼
- [ ] 白话无占断口吻、无未授权现代译注抄袭
- [ ] 乾用九、坤用六原文无误
- [ ] 内容负责人签署可再分发
签署前,P2 不得标记完成,商店发布不得进行。
+4 -2
View File
@@ -1,6 +1,6 @@
# 数据、内容与隐私契约
> 状态:结构已定义,内容来源与授权仍为发布阻塞项
> 状态:`zh-Hans-2026.1` 已接入应用;白话为草稿,发布仍待审校
> 适用范围:卦库 assets、Room、DataStore、导入脚本和内容审核
## 1. 数据分类
@@ -58,7 +58,7 @@
示例中的省略号不是可发布内容。禁止由 AI 在构建时临时补齐缺失卦辞或爻辞。
机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。Android parser 尚未建立前,`scripts/verify-content-contract.mjs` 已提供独立构建期校验、稳定 SHA-256 摘要和不含可发布卦辞的自动化夹具;`HexagramContentRepository` 接口与测试 fake 已建立,缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。
机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。`scripts/verify-content-contract.mjs` 校验夹具与 `content/packages/hexagram-content.json`;Kotlin `HexagramContentParser` 用同一套规则加载 assets。`HexagramContentRepository` 在缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。来源与审校状态见 [内容审校](content-review.md)。
## 3. 内容完整性门禁
@@ -88,6 +88,8 @@
## 5. Room 数据模型提案
当前实现只有 `CastingSessionEntity` / `casting_sessions`(schema version 1)。`ExplanationEntity` 与 `ActionNoteEntity` 仍是提案,不得在 UI 中假装已经保存解读或行动。
历史记录不是起卦事实源,但应保存可复核的快照:
```text
+36 -5
View File
@@ -131,16 +131,47 @@
- 后果:`spotlessCheck` 当前是仓库内的无依赖格式兼容入口,不表示已引入 Spotless 插件。Android 壳建立后必须把 lint/debug build 加入 `verifyLocal`;将来若采用维护良好的专用插件,应保留命令兼容或同步更新 CI 与文档。
- 复审触发:Android 工具链启用、现有脚本无法表达新边界,或专用工具能以可接受成本提供明显更强的检查。
## ADR-014:正式 Android 身份与最低版本
- 状态:`Accepted`
- 日期:2026-08-07
- 关联:解决 TBD-001 中的产品名、TBD-002、TBD-003;2026-08-07 用户确认
- 决定:正式产品名为“灵机”;用户提供的组织域名 `flash.opcapp.net` 按 Android 反向域名规则映射为 namespace/application ID `net.opcapp.flash`;`minSdk=26`。仓库根名 `brainwave` 继续只作内部代码代号,不进入 Android 发布身份。
- 原因:稳定的正式身份是创建可安装应用、生成类路径、配置清单和建立设备测试的前置条件;API 26 与已接受架构及当前依赖兼容。
- 备选:使用临时 application ID 会形成迁移、数据目录与签名身份风险;继续等待会阻塞 P0。`flash.opcapp.net` 不能直接作为 Android application ID,因为发布标识采用反向域名。
- 后果:主代码迁移到 `net.opcapp.flash`;Android 壳使用“灵机”和 `minSdk=26`。应用图标、商店文案和签名仍未由本决策确认。当前 `compileSdk/targetSdk=34` 是已安装工具链基线,不代表永久商店目标;发布前须按当时商店要求复核升级。
- 复审触发:组织域名所有权变化、发布账号要求更换 ID,或依赖/覆盖率数据要求提高最低系统版本。application ID 一旦发布不得轻率更换。
## ADR-015:自有白话 + 维基文库公版《易经》原文
- 状态:`Accepted`
- 日期:2026-08-19
- 关联:TBD-005、FR-R-005、P2
- 决定:安装包内的经典原文取自维基文库《周易》的《易经》部分(卦辞、爻辞,以及乾用九、坤用六),不含十翼;现代白话由本项目自撰,标为草稿,待内容负责人审校后才视为可再分发。不把南怀瑾、傅佩荣等现代译注入 APK。
- 原因:需要可核验、可再分发的公版原文,同时避免把仍受版权保护的现代译文误当成古籍公版。
- 备选:等待商业授权会继续阻塞离线阅读;使用来源不明的网络译文无法通过内容门禁。
- 后果:原文事实源为 `content/raw/zhouyi-wikisource-jing.json`,经 `scripts/import-zhouyi-wikisource.mjs` 导入并由 Wikimedia `zh-hans` 转换;白话事实源为 `content/plain/lingji-plain.mjs`;发布包为 `content/packages/hexagram-content.json`,构建时拷入 assets。应用内必须区分“原文”与“本地白话”,并提供内容来源页。P2 在审校签核前不得标为完成。
- 复审条件:维基文库页面结构变化、发现与通行本有影响阅读的异文、或内容负责人改用另一公版底本。
## ADR-016:乾用九、坤用六在内容具备时展示
- 状态:`Accepted`
- 日期:2026-08-19
- 关联:TBD-006、P2/P3
- 决定:内容包声明并收录乾“用九”、坤“用六”。结果页仅在本卦为乾且六爻皆老阳,或本卦为坤且六爻皆老阴时展示对应特殊文本。
- 原因:用九/用六是《易经》文本的一部分,不是另造的占断;只有全动时才按传统用法出示,避免静卦或单爻动时误读。
- 备选:一律隐藏会丢掉已具备的原文;凡见乾坤都展示会把特殊用法当成普通爻辞。
- 后果:`specialUsageTexts.qian/kun` 必须为 true,且仅乾、坤可有 `specialUsageText`。之卦为乾坤但不满足全九/全六时不展示用九/用六。
- 复审条件:产品决定对之卦或非全动情形也提示该文本。
## 未决问题
| ID | 问题 | 推荐默认 | 阻塞阶段 |
|---|---|---|---|
| TBD-001 | 正式产品名与应用图标 | Brainwave 仅作代码代号 | P0 商店配置 |
| TBD-002 | application ID | 使用组织所有的反向域名 | P0 |
| TBD-003 | minSdk | 26;创建工程时复核覆盖率和依赖要求 | P0 |
| TBD-001 | 应用图标与商店素材 | 产品名已由 ADR-014 确认为“灵机”;图标不使用未确认成稿 | P6 商店配置 |
| TBD-004 | 问题是否允许留空 | 允许选择“不写具体内容”,但需显式操作 | P3 |
| TBD-005 | 经典原文、现代白话的版本与授权 | 自有白话 + 可核验公版原文 | P2,发布阻塞 |
| TBD-006 | 乾用九、坤用六是否纳入 MVP | 内容具备时展示 | P2/P3 |
| TBD-005 | 经典原文、现代白话的版本与授权 | 已由 ADR-015 接受;内容负责人审校仍阻塞发布 | P2,发布阻塞 |
| TBD-006 | 乾用九、坤用六是否纳入 MVP | 已由 ADR-016 接受:全九/全六时展示 | P2/P3 |
| TBD-009 | AI 模型供应商与自有后端 | 供应商无关接口;先交付本地版 | P5 |
| TBD-010 | 服务端问题/回复保留期 | 最小化且明确披露,优先不持久化正文 | P5,发布阻塞 |
| TBD-011 | 高风险本地资源表覆盖地区 | 首发市场确认后维护,不让模型编号码 | P5 |
+37 -10
View File
@@ -1,17 +1,44 @@
# 依赖与许可证清单
> 状态:当前 JVM harness 已核验;Android application 依赖尚未配置
> 适用范围:实际解析的 JVM runtime/test graph,以及直接使用的构建入口
> 状态:Android debug runtime、debug unit-test runtime 与 debug instrumentation-test runtime 依赖图已建立自动覆盖门禁
> 适用范围:实际解析的 Android 调试运行时、单元测试/仪器测试运行时,以及直接使用的构建入口
本清单不是未来 Android APK 的最终 third-party notices。每次新增或升级依赖时,必须同步 `config/dependency-licenses.json` 并运行 `.\gradlew.bat verifyLocal --offline`;Android 依赖启用后,还要生成 release runtime 的完整报告并复核是否需要在应用或分发包中附带许可证文本。
本清单不是上架包的最终 third-party notices。每次新增或升级依赖时,必须同步 `config/dependency-licenses.json` 并运行 `.\gradlew.bat verifyLocal`;发布前仍须对 release runtime 生成完整报告,复核许可证文本与随包义务。
## 直接条目
| ID | 版本 | 当前范围 | SPDX | 权威许可来源 |
|---|---:|---|---|---|
| `gradle-wrapper` | 8.2 | 构建入口 | Apache-2.0 | [Gradle 8.2 license](https://github.com/gradle/gradle/blob/v8.2.0/LICENSE) |
| `kotlin-gradle-plugin` | 1.9.20 | 构建插件 | Apache-2.0 | [Kotlin 1.9.20 license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
| `kotlin-stdlib` | 1.9.20 | JVM runtime | Apache-2.0 | [Kotlin 1.9.20 license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
| `jetbrains-annotations` | 13.0 | JVM runtime transitive | Apache-2.0 | [Maven artifact metadata](https://repo1.maven.org/maven2/org/jetbrains/annotations/13.0/annotations-13.0.pom) |
| `junit4` | 4.13.2 | test | EPL-1.0 | [JUnit 4.13.2 license](https://github.com/junit-team/junit4/blob/r4.13.2/LICENSE-junit.txt) |
| `hamcrest-core` | 1.3 | test transitive | BSD-3-Clause | [Hamcrest 1.3 license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) |
| `gradle-wrapper` | 8.2 | 构建入口 | Apache-2.0 | [Gradle license](https://github.com/gradle/gradle/blob/v8.2.0/LICENSE) |
| `android-gradle-plugin` | 8.2.0 | 构建插件 | Apache-2.0 | [Android tools license](https://android.googlesource.com/platform/tools/base/+/studio-2022.2.1-patch4/LICENSE) |
| `kotlin-gradle-plugin` | 1.9.20 | 构建插件 | Apache-2.0 | [Kotlin license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
| `hilt-gradle-plugin` | 2.48.1 | 构建插件 | Apache-2.0 | [Dagger/Hilt license](https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt) |
| `compose-bom` | 2023.10.01 | 运行时版本约束 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
| `navigation-compose` | 2.7.5 | 运行时 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
| `room-runtime` | 2.6.1 | 运行时 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
| `datastore-preferences` | 1.0.0 | 运行时 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
| `hilt-android` | 2.48.1 | 运行时 | Apache-2.0 | [Dagger/Hilt license](https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt) |
| `kotlin-stdlib` | 1.9.20 | 运行时 | Apache-2.0 | [Kotlin license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
| `jetbrains-annotations` | 23.0.0 | 运行时传递依赖 | Apache-2.0 | [Maven artifact metadata](https://repo1.maven.org/maven2/org/jetbrains/annotations/23.0.0/annotations-23.0.0.pom) |
| `jsr305` | 3.0.2 | 运行时传递依赖 | BSD-3-Clause | [Maven artifact metadata](https://repo1.maven.org/maven2/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.pom) |
| `guava-listenablefuture` | 1.0 | 运行时传递依赖 | Apache-2.0 | [Maven artifact metadata](https://repo1.maven.org/maven2/com/google/guava/listenablefuture/1.0/listenablefuture-1.0.pom) |
| `javax-inject` | 1 | 运行时传递依赖 | Apache-2.0 | [Maven artifact metadata](https://repo1.maven.org/maven2/javax/inject/javax.inject/1/javax.inject-1.pom) |
| `junit4` | 4.13.2 | 单元测试 | EPL-1.0 | [JUnit license](https://github.com/junit-team/junit4/blob/r4.13.2/LICENSE-junit.txt) |
| `hamcrest-core` | 1.3 | 单元测试传递依赖 | BSD-3-Clause | [Hamcrest license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) |
| `hamcrest-library` | 1.3 | 仪器测试传递依赖 | BSD-3-Clause | [Hamcrest license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) |
| `hamcrest-integration` | 1.3 | 仪器测试传递依赖 | BSD-3-Clause | [Hamcrest license](https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt) |
| `gson` | 2.9.0 | 仪器测试传递依赖 | Apache-2.0 | [Gson license](https://github.com/google/gson/blob/gson-parent-2.9.0/LICENSE) |
| `javawriter` | 2.1.1 | 仪器测试传递依赖 | Apache-2.0 | [JavaWriter license](https://github.com/square/javawriter/blob/javawriter-2.1.1/LICENSE.txt) |
当前没有第三方 Android runtime 库、字体、纹理、插画、音效或可发布《易经》内容进入工程;这句话只描述本提交时的依赖图,不构成未来授权。
## 传递依赖组策略
下列策略只允许当前解析图中的 Maven group 命中对应许可证;未知 group 会让 Gradle 门禁失败,工程自身产物不参与第三方检查。
| ID | Maven group 前缀 | 当前范围 | SPDX | 权威许可来源 |
|---|---|---|---|---|
| `androidx-runtime-policy` | `androidx` | 运行时传递依赖 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt) |
| `dagger-runtime-policy` | `com.google.dagger` | 运行时传递依赖 | Apache-2.0 | [Dagger/Hilt license](https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt) |
| `kotlin-runtime-policy` | `org.jetbrains.kotlin` | 运行时传递依赖 | Apache-2.0 | [Kotlin license](https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt) |
| `kotlinx-runtime-policy` | `org.jetbrains.kotlinx` | 运行时传递依赖 | Apache-2.0 | [Kotlinx Coroutines license](https://github.com/Kotlin/kotlinx.coroutines/blob/1.7.1/LICENSE.txt) |
当前没有第三方字体、纹理、插画或音效进入工程。应用使用 Android 系统字体、代码绘制的卦象,以及 `content/packages/hexagram-content.json` 中的维基文库公版《易经》与项目自撰白话草稿;这句话只描述本提交时的资源图,不构成内容负责人已签核。
+1 -1
View File
@@ -5,7 +5,7 @@
本文件定义本项目唯一允许的计算规则。UI 文案、AI 输出和数据源都不能覆盖这些规则。
实现状态:`coin-v1` 纯 Kotlin 核心位于 `app/src/main/java/brainwave/domain/casting/`;`.\gradlew.bat :app:test --offline` 覆盖 8 种币面、4,096 种六爻、64 模式、已知夹具和 DTO 往返。记录时间与内容版本在确定性计算完成后附加。
实现状态:`coin-v1` 纯 Kotlin 核心位于 `app/src/main/java/net/opcapp/flash/domain/casting/`;`.\gradlew.bat :app:testDebugUnitTest` 覆盖 8 种币面、4,096 种六爻、64 模式、已知夹具和 DTO 往返。记录时间与内容版本在确定性计算完成后附加。
## 1. 术语和类型
+14 -17
View File
@@ -1,7 +1,7 @@
# 本地开发环境基线
> 状态:已从当前主机实测
> 最后核验:2026-08-04
> 最后核验:2026-08-19
> 适用范围:`D:\OPC\brainwave` 的 Android 开发、构建和设备验证
本文件记录项目当前可用的本地工具链,不是期望环境清单。后续实现必须先使用这里已经确认的能力;需要升级或安装新工具时,应说明原因并在完成后更新本文件。
@@ -39,7 +39,7 @@
| Node.js / npm | `22.22.1` / `11.12.1` |
| Google Chrome | `150.0.7871.188`,`C:\Program Files\Google\Chrome\Application\chrome.exe` |
首次环境审计时仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。当前这些仓库级入口已经建立;`app` 暂时是纯 Kotlin/JVM 领域 harness,不是可安装 Android application。
首次环境审计时仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。当前这些入口和可安装的 Android application 已建立;正式应用名为“灵机”,application ID 为 `net.opcapp.flash`。
Windows 路径可能较长;如果后续依赖缓存或生成代码触发路径长度错误,应优先缩短包/生成目录或评估仓库级长路径配置,并把实际决定写入[决策记录](decisions.md)。
@@ -111,23 +111,21 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
.\gradlew.bat <task>
```
不要要求用户安装全局 Gradle。当前缓存已实际证明 Gradle 8.2、Kotlin 1.9.20 与 JUnit 4.13.2 可离线完成领域构建和测试;Compose、Hilt、Room 等 Android 依赖仍未配置或验证,不能据此推断可离线解析。
不要要求用户安装全局 Gradle。当前缓存已实际证明 Gradle 8.2、AGP 8.2.0、Kotlin 1.9.20、Compose、Hilt、Room、DataStore、Navigation 与 JUnit 可完成 Android debug 构建、lint、单元测试和测试 APK 编译;首次解析已经联网完成,后续离线可用性仍以实际命令为准。
## 7. 已连接 Android 真机
检测时存在 1 台已授权的物理设备。为避免持久化设备标识,本文件不记录序列号。
2026-08-07 本轮曾检测到 1 台已授权的物理设备。为避免持久化设备标识,本文件不记录序列号。
| 项目 | 实测值 |
|---|---|
| 厂商/型号 | OnePlus `PKG110` |
| Android | 16 |
| API Level | 36 |
| ABI | `arm64-v8a` |
| 物理分辨率 | `1264 × 2780` |
| 当前覆盖分辨率 | `1080 × 2376` |
| 物理/覆盖密度 | `560 / 480 dpi` |
| 厂商/型号 | Samsung `SM-G9700` |
| Android | 12 |
| API Level | 31 |
| 物理分辨率 | `1080 × 2280` |
| 物理密度 | `480 dpi` |
当前设备可用于 `adb` 安装、手工验收和 `connectedDebugAndroidTest`。测试脚本不得硬编码序列号;设备可能随时断开,纯 JVM 门禁必须独立可运行。
该设备满足 `minSdk=26`,可用于 `adb` 安装、手工验收和仪器测试。测试脚本不得硬编码序列号;设备可能随时断开,环境独立门禁必须单独可运行。本机 `PATH` 中的 `D:\Portable\adb\adb.exe` 为 ADB 1.0.32,SDK Platform Tools 37.0.0 提供 ADB 1.0.41;两者会争用 5037 端口并互相重启 server。本轮三星设备只在便携 ADB server 下稳定枚举,SDK ADB 重启后设备接口消失,因此真机门禁通过 `run-connected-tests.ps1 -AdbPath` 显式选择单一 ADB。不要同时启动两个 server,也不要把“Windows 能看到 USB 复合设备”误当作 ADB 已连接。
## 8. 网络与代理状态
@@ -146,21 +144,20 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
2. 使用项目自带 Gradle Wrapper,不依赖全局 `gradle` 或 `kotlinc`。
3. 首次可构建配置只能依赖已安装的 SDK Platform 34 和 Build Tools 34.0.0;若模板要求更高版本,应先记录差异,再决定调整模板或安装 SDK。
4. 命令行构建是必须能力,不假设 Android Studio 存在。
5. 仪器测试优先使用已连接的 API 36、arm64 真机;不假设 Emulator/AVD 存在。
5. 仪器测试优先使用当前可用的 API 31 三星真机;不假设 Emulator/AVD 或固定设备存在。
6. 不自动安装系统级工具、SDK 平台或模拟器;确需新增环境能力时,先说明影响。
7. 发布时的 `targetSdk` 和商店要求是独立发布门禁,不能因为本机只有 Platform 34 就永久锁定;工具链升级后必须更新本文件和相关 ADR。
8. P-1 原型截图使用现有 Node.js 与 Chrome DevTools 协议生成,不额外安装前端依赖;命令见[移动端交互原型](prototype.md#3-运行方式)。
## 10. 已知缺口
- 可安装的 Android application 壳尚未创建;正式名称、组织所有的 application ID 与最终 `minSdk` 仍待确认。
- Android Studio 未安装。
- Android Emulator、system image 和 AVD 不可用。
- 本机只确认安装了 Android Platform 34 / Build Tools 34.0.0。
- Compose、Hilt、Room、DataStore 与 Navigation 的 Maven 依赖尚未完整离线验证。
- 代理已配置,但外部仓库和 Android CLI 网络连通性尚未验证。
- `PATH` 中 ADB 1.0.32 与 SDK ADB 1.0.41 会争用 server;当前三星设备的可重复门禁需要显式选择便携 ADB,后续应评估统一驱动和 Platform Tools。
- 代理已配置;本轮 Maven 依赖下载成功,但不能据此保证未来网络始终可用。
已解除的缺口:Gradle Wrapper、Version Catalog、根 `AGENTS.md`、CI 与可执行的 `verifyLocal` 已建立;`verifyLocal --offline` 已在 JDK 17 上通过。它当前不包含 Android lint、APK 构建或设备测试。
已解除的缺口:Gradle Wrapper、Version Catalog、根 `AGENTS.md`、CI、正式 Android 身份和 application 壳均已建立。2026-08-19,`verifyLocal` 通过(含 debug 单元测试、`lintDebug` 与 debug APK,已校验 `zh-Hans-2026.1` 内容包);便携 ADB 下 Samsung API 31 真机 `run-connected-tests.ps1` 得到 `OK (4 tests)`,覆盖方法说明、保存设置、夹具「复 24 → 坤 2、初爻动」本机保存/当次删除(结果页已展示原文/白话),以及 Room 会话读写。未发现应用崩溃日志。飞行模式人工走查、无障碍抽测和内容负责人签核仍未做。
这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
+35 -31
View File
@@ -1,6 +1,6 @@
# 分阶段实施计划
> 状态:P-1 v0.3 待视觉复核;P0 仓库门禁已建立但 Android 壳受 TBD-001~003 阻塞;P1 已完成并通过穷举测试;P2 内容契约已建立但授权内容未开始
> 状态:P0/P1 已完成;P3 离线主流程已落地但未达到 UX 全表验收;P4 会话保存已落地,问卦簿/解读未开始;P2 已接入公版《易经》与自撰白话草稿,待审校签核;P-1 v0.3 待视觉复核
> 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI
## 1. 依赖图
@@ -47,11 +47,11 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
任务:
- [x] 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。
- [ ] 基于 Google `android/architecture-templates` 的 `base` 分支初始化 Android 壳;模板定制需要 TBD-002 的正式 application ID,不能用临时发布身份替代。
- [ ] 确认正式应用名称、package/application ID、minSdk(TBD-001~003)。
- [ ] 配置 Compose、Material 3、Hilt、Room、DataStore 和 Navigation;Version Catalog 已先用于 JVM harness。
- [ ] 配置 Gradle Wrapper、格式化、lint、单元测试和 CI:Wrapper、无依赖格式门禁、JVM 单测与 CI 已完成;Android lint 要等 application 插件启用。
- [ ] 建立 [系统架构](architecture.md)中的包结构和空 feature 边界:`domain/casting` 已落地,其余随 Android 壳建立。
- [x] 参照 Google `android/architecture-templates` 的 `base` 分支建立单模块 Android 壳和分层边界。
- [x] 确认正式应用名称“灵机”、namespace/application ID `net.opcapp.flash`、`minSdk=26`(ADR-014)。
- [x] 配置 Compose、Material 3、Hilt、Room、DataStore 和 Navigation,并由 Version Catalog 固定兼容版本。
- [x] 配置 Gradle Wrapper、格式、Android lint、单元测试、debug APK、依赖许可证门禁和 CI 同入口验证。
- [x] 建立 [系统架构](architecture.md)中的 `app`、`core/designsystem`、`domain`、`data`、`feature/home` 与 `feature/result` 边界;未实施 feature 不创建空包。
- [x] 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。
- [x] 增加 `verifyLocal` 聚合任务、文档链接、领域边界、内容契约与基础 secret scan。
@@ -59,9 +59,11 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- Windows 上一条命令可执行格式、lint、单测和 debug 构建。
- CI 使用同一组命令。
- 空应用可在模拟器启动,导航到占位欢迎页。
- 开发验证应用可在授权真机启动,并从首页导航到已知领域夹具结果页。
- 没有生产服务密钥或真实内容。
完成证据(2026-08-08):`verifyLocal`、13 个 debug 单元测试、`lintDebug`、主/测试 APK 构建均通过;调试 APK 在 Samsung API 31 设备冷启动到 `MainActivity`,`MainActivityTest` 真机执行 1/1 通过,并验证首页进入“复 24 → 坤 2、初爻动”结果页。因本机 ADB 1.0.32/1.0.41 server 冲突,设备门禁使用仓库脚本显式选择可枚举该设备的 ADB,未改写 SDK 工具。
## 4. P1:领域核心
目标:在纯 Kotlin 中完成并证明三枚铜币算法。
@@ -82,7 +84,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- 测试无随机、无网络、无系统时间依赖。
- `CastEngine` API 经评审后冻结为 `coin-v1`。
当前证据:`.\gradlew.bat :app:test --offline` 中 `CastEngineTest` 执行 10 个测试、0 失败;实现位于 `app/src/main/java/brainwave/domain/casting/`。`brainwave` 是内部代码命名空间,不是 TBD-002 的 application ID。
当前证据:`.\gradlew.bat :app:testDebugUnitTest` 中 `CastEngineTest` 执行 10 个测试、0 失败;实现位于 `app/src/main/java/net/opcapp/flash/domain/casting/`,且领域边界脚本禁止 Android/Compose/数据库/网络依赖。
## 5. P2:内容数据管线
@@ -90,18 +92,18 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
任务:
- [ ] 决定原文版本、现代白话来源和授权(TBD-005,发布阻塞)。
- [ ] 实现 JSON schema、解析器和内容版本:`schemaVersion=1` 的机器 schema 已完成;Android/Kotlin assets 解析器待 Android 壳建立。
- [ ] 录入/导入 64 卦、卦辞、384 条爻辞及所需特殊文本。
- [ ] 建立来源清单、许可证清单和内容审核记录;schema 已强制每个来源包含版本、许可证与 URL。
- [x] 决定原文版本、现代白话来源和授权(TBD-005):ADR-015 接受“自有白话 + 维基文库公版《易经》原文”;发布仍待审校签核。
- [x] 实现 JSON schema、解析器和内容版本:`schemaVersion=1` 的机器 schema、Kotlin assets 解析器与 `zh-Hans-2026.1` 包已落地。
- [x] 录入/导入 64 卦、卦辞、384 条爻辞及乾用九、坤用六;白话为项目自撰草稿。
- [x] 建立来源清单、许可证清单和内容审核记录:见 [内容审校](content-review.md);schema 已强制每个来源包含版本、许可证与 URL。
- [x] 实现构建期完整性校验、文王序号/上下卦/bottom-up 交叉校验、稳定 SHA-256 摘要及 8 个负向夹具。
- [ ] 实现 `HexagramContentRepository` fake 与 assets 版本:接口、强校验只读模型和测试 fake 已完成,assets 实现待 Android parser。
- [x] 实现 `HexagramContentRepository` fake 与 assets 版本:加载失败时展示完整性错误,不回退到相邻卦。
退出条件:
- 64 卦/384 爻数据完整、唯一且来源可追踪。
- 缺失、重复、非法顺序和错误映射测试均能失败。
- 内容负责人确认可再分发。
- [x] 64 卦/384 爻数据完整、唯一且来源可追踪。
- [x] 缺失、重复、非法顺序和错误映射测试均能失败。
- [ ] 内容负责人确认可再分发。
## 6. P3:核心用户流程与东方设计系统
@@ -109,13 +111,13 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
任务:
- 实现颜色、字体、间距、形状和动画令牌。
- 实现卦象 `Canvas`、动爻标记和读屏语义。
- 实现首次说明、起念、投币和结果页面。
- 实现 `CastingSessionViewModel` 状态机及 SavedState 恢复。
- 支持前五轮返回修改、第六轮封印和明确重新起卦。
- 接入本地内容并区分原文/本地白话。
- 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配。
- [x] 实现颜色、字体、间距、形状和动画令牌:当前为 `LingjiTheme` 纸墨朱砂配色与系统衬线/无衬线配对;独立 token 文件、指定字体包和动效仍未做。
- [x] 实现卦象 `Canvas`、动爻标记和读屏语义:结果页绘制本卦/之卦,动爻同时使用颜色、圆点和文字。
- [x] 实现首次说明、起念、投币和结果页面:方法说明、问题、六轮手录和结果已接入导航;空问题仍被拒绝,TBD-004 未决。
- [x] 实现 `CastingViewModel` 状态机及 SavedState 恢复:问题、已确认爻和当前轮次写入 `SavedStateHandle`;第六轮才调用 `CastEngine`。
- [x] 支持前五轮返回修改、第六轮封印和明确重新起卦。
- [x] 接入本地内容并区分原文/本地白话:结果页展示本卦卦辞、动爻爻辞和之卦卦辞,并标注原文/白话;乾全九、坤全六时展示用九/用六。白话仍为待审校草稿。
- [ ] 完成深浅主题、字体缩放、TalkBack、横屏和大屏适配:深浅色随系统;其余未做发布级验收。
退出条件:
@@ -124,20 +126,22 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- AI/网络代码尚未存在也不影响流程。
- [UX 与东方视觉](ux-design.md)检查表通过。
当前证据(2026-08-19):`verifyLocal` 通过;仪器测试覆盖方法说明、保存设置和夹具「复 24 → 坤 2、初爻动」的本机保存/当次删除。P3 仍未退出,因为「解」、问卦簿入口和 UX 全表未完成。飞行模式未做人工走查。内容包已接入但白话待审校。
## 7. P4:本地解释与历史
目标:形成不依赖 AI 的完整 MVP。
任务:
- 实现版本化本地解释模板。
- 实现“可以试的一小步”的非裁决式结构。
- 实现 Room 历史:第六爻锁定后按策略自动保存快照,问题原文、当次解读和行动记录默认保存到同一会话。
- 实现 `autoSaveHistory` 总开关及三个默认开启的内容开关;支持总开关关闭后的“保存本次”和全局设置不变的“本次不保存/删除本次记录”。
- 实现按时间倒序的问卦簿、派生“最近一次”、空态、详情、解释版本、单条删除和清空全部确认。
- 在首页实现固定本机保存/AI 发送边界说明和设置入口;本地保存设置与 AI 同意版本保持独立。
- 配置 backup/data-extraction 规则,排除历史 Room 数据库及辅助文件。
- 完成 migration、策略组合、事务关联、删除、备份排除、隐私和离线测试。
- [ ] 实现版本化本地解释模板。
- [ ] 实现“可以试的一小步”的非裁决式结构。
- [x] 实现 Room 历史会话:第六爻锁定后按 `autoSaveHistory` 写入 `casting_sessions` 快照;问题原文受内容开关控制。解读与行动表尚未建立。
- [x] 实现 `autoSaveHistory` 总开关及三个默认开启的内容开关;总开关关闭后结果页提供“保存本次”,已保存时可“本次不保存/删除本次记录”。解读/行动开关目前只保留偏好,不写入任何正文。
- [ ] 实现按时间倒序的问卦簿、空态、详情、解释版本、单条删除和清空全部确认。首页仅展示条数与最近一次派生摘要,不进入列表。
- [x] 在首页实现固定本机保存/AI 发送边界说明和设置入口;本地保存设置与 AI 同意版本保持独立。
- [x] 配置 backup/data-extraction 规则,排除历史 Room 数据库及辅助文件;`verifyLocalDataBoundary` 锁定 `allowBackup=false` 且无 INTERNET 权限。
- [ ] 完成 migration、策略组合、事务关联、删除、备份排除、隐私和离线测试:schema 1 无需迁移;编解码、Room 仪器测试和首页设置仪器测试已有,策略组合与级联删除未覆盖。
退出条件:
+1 -1
View File
@@ -1,7 +1,7 @@
# 产品规格
> 状态:MVP 方案基线
> 产品代号:Brainwave(正式名称 TBD)
> 产品代号:Brainwave(正式名称:灵机)
> 原始输入:[原始需求.txt](原始需求.txt)
## 1. 产品定义
+1 -1
View File
@@ -8,7 +8,7 @@
在初始化 Android 工程前,先用可点击原型确认最容易返工的产品决策:信息层级、六次录入方式、结果阅读顺序、AI 同意门和东方文化表达。原型确认后,P3 的 Compose 页面应复用这里的设计令牌和状态契约,而不是重新发明流程。
本轮使用“一问”作为视觉工作名。它不构成正式命名决定;正式名称、应用图标和商店素材仍是 [TBD-001](decisions.md#未决问题)。
v0.3 截图仍保留当时的视觉工作名“一问”,不得当作当前产品文案。正式产品名已由 [ADR-014](decisions.md#adr-014正式-android-身份与最低版本) 确认为“灵机”;应用图标与商店素材仍见 [TBD-001](decisions.md#未决问题),P3 固化正式页面前须刷新原型品牌文案。
## 2. 交付范围
+12 -11
View File
@@ -1,6 +1,6 @@
# 质量门禁与验证策略
> 状态:JVM/harness 门禁已启用;Android lint、构建、UI 与设备门禁等待 Android application 壳
> 状态:Android 本地门禁已启用;设备门禁在授权真机存在时单独执行
> 原则:完成必须有可重复证据,不能以“代码看起来正确”代替验证
## 1. 反馈循环
@@ -21,26 +21,27 @@
```powershell
.\gradlew.bat spotlessCheck --offline
.\gradlew.bat :app:test --offline
.\gradlew.bat :app:testDebugUnitTest --offline
.\gradlew.bat verifyContentContract --offline
.\gradlew.bat verifyLocal --offline
.\gradlew.bat verifyLocal
```
`verifyLocal` 当前聚合无依赖格式检查、10 个领域测试、3 个内容 repository 测试、已解析 JVM 依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan 和原型 JavaScript 语法检查。CI 执行同一个聚合任务。
`verifyLocal` 聚合无依赖格式检查、领域穷举测试、内容 repository 测试、历史编解码测试、问题校验测试、Android lint、debug APK、已解析 Android 调试/单测/仪器测试依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan、原型 JavaScript 语法检查,以及本机备份排除/无 INTERNET 权限检查。CI 执行同一个聚合任务。首次解析 Android 依赖时不能强制 `--offline`;缓存完成后可用离线模式复核。
Android application 插件配置后,Windows 环境还必须提供:
授权真机存在时,Windows 环境还必须执行:
```powershell
.\gradlew.bat spotlessCheck
.\gradlew.bat lintDebug
.\gradlew.bat testDebugUnitTest
.\gradlew.bat assembleDebug
.\gradlew.bat connectedDebugAndroidTest
```
若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。`connectedDebugAndroidTest` 需要模拟器或设备,应与纯 JVM 快速门禁分开。
若本机存在两个不兼容的 ADB server,可在只保留一个 server 的前提下使用等价的可重复入口:
到那时必须把 Android lint 和 debug build 加入现有 `verifyLocal`,使代理仍不必猜测验证组合。在完成这一步之前,`verifyLocal` 通过只证明 JVM 与仓库门禁,不代表 APK 已构建或真机测试已通过。
```powershell
.\scripts\run-connected-tests.ps1 -AdbPath <能够稳定识别设备的 adb.exe>
```
该脚本重新构建主/测试 APK、检查授权状态、覆盖安装两者并直接调用同一个 `AndroidJUnitRunner`;失败、零测试或缺少 `OK` 结果都会使脚本失败。若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。设备测试继续与环境独立的 `verifyLocal` 分开;`verifyLocal` 通过证明 APK 已构建,但不等于真机测试已经通过。
## 3. 测试层次
@@ -127,7 +128,7 @@ Android application 插件配置后,Windows 环境还必须提供:
| FR-C-007 | SavedState/重建测试 | 旋转、切后台、进程恢复 |
| FR-R-001~003 | UI 语义与 screenshot 测试 | TalkBack、色觉与长文阅读 |
| FR-R-004 | 网络失败测试 | 飞行模式 |
| FR-R-005 | 内容 schema/授权清单检查 | 内容负责人签核 |
| FR-R-005 | 内容 schema/授权清单检查 | 自动校验已接入;内容负责人签核仍待完成 |
| FR-E-001~003 | 网络调用次数与同意状态测试 | 首次同意流程 |
| FR-E-004~006 | 输出 schema + 安全用例集 | 安全/产品审核 |
| FR-H-001~008 | 保存策略、Room 事务/版本、删除、备份排除、零网络调用测试 | 首页告知、设置、当次退出与删除体验 |
+1
View File
@@ -2,6 +2,7 @@
> 状态:MVP 设计基线
> 适用范围:Android 手机优先,兼顾横屏、平板和系统无障碍设置
> 当前实现:Compose 已覆盖方法说明、回访首页、起念、六轮手录、卦名/动爻结果、原文/本地白话和保存设置;问卦簿列表与「解」仍未进入应用,不能把本文件尚未实现的页面当成已上线功能
## 1. 体验定位
+3
View File
@@ -0,0 +1,3 @@
android.useAndroidX=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+38 -1
View File
@@ -1,9 +1,46 @@
[versions]
agp = "8.2.0"
kotlin = "1.9.20"
composeBom = "2023.10.01"
composeCompiler = "1.5.5"
coreKtx = "1.12.0"
activityCompose = "1.8.1"
lifecycle = "2.6.2"
navigation = "2.7.5"
room = "2.6.1"
dataStore = "1.0.0"
hilt = "2.48.1"
junit = "4.13.2"
androidxTestExt = "1.1.5"
androidxTestRunner = "1.5.2"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "dataStore" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExt" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+85
View File
@@ -0,0 +1,85 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { kingWenEntries, validateContentPackage } from "./content-contract.mjs";
import { repositoryRoot } from "./repository-files.mjs";
import { lingjiPlain } from "../content/plain/lingji-plain.mjs";
const names = [
"乾", "坤", "屯", "蒙", "需", "讼", "师", "比",
"小畜", "履", "泰", "否", "同人", "大有", "谦", "豫",
"随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复",
"无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒",
"遁", "大壮", "晋", "明夷", "家人", "睽", "蹇", "解",
"损", "益", "夬", "姤", "萃", "升", "困", "井",
"革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅",
"巽", "兑", "涣", "节", "中孚", "小过", "既济", "未济",
];
if (lingjiPlain.length !== 64) {
throw new Error(`lingji plain table must contain 64 hexagrams, got ${lingjiPlain.length}`);
}
const jingPath = path.join(repositoryRoot, "content", "raw", "zhouyi-wikisource-jing.json");
const jing = JSON.parse(await readFile(jingPath, "utf8"));
if (!Array.isArray(jing) || jing.length !== 64) {
throw new Error("Wikisource jing import must contain 64 hexagrams; run scripts/import-zhouyi-wikisource.mjs");
}
const jingById = new Map(jing.map((item) => [item.kingWenNumber, item]));
const entries = kingWenEntries();
const originalSource = "wikisource-zhouyi-jing-zh-Hans";
const plainSource = "lingji-plain-zh-Hans-2026.1";
const contentPackage = {
schemaVersion: 1,
contentVersion: "zh-Hans-2026.1",
specialUsageTexts: { qian: true, kun: true },
sources: [
{
id: originalSource,
title: "维基文库《周易》易经",
edition: "仅卦辞、爻辞与乾用九、坤用六,不含十翼;由 zh.wikisource.org 导入并经 Wikimedia zh-hans 转换",
license: "公版(Wikimedia PD-old)",
url: "https://zh.wikisource.org/wiki/周易",
},
{
id: plainSource,
title: "灵机本地白话",
edition: "2026.1 项目自撰草稿,待内容负责人审校后视为可再分发",
license: "项目自撰;审校完成前仅用于本机构建,不以现代译注冒充公版",
url: "https://creativecommons.org/licenses/by/4.0/deed.zh-Hans",
},
],
hexagrams: entries.map((entry) => {
const original = jingById.get(entry.kingWenNumber);
if (!original) {
throw new Error(`Missing jing text for King Wen ${entry.kingWenNumber}`);
}
const plain = lingjiPlain[entry.kingWenNumber - 1];
if (!Array.isArray(plain) || plain.length !== 7 || plain.some((text) => !String(text).trim())) {
throw new Error(`Plain text for King Wen ${entry.kingWenNumber} must contain 7 non-blank strings`);
}
return {
...entry,
name: names[entry.kingWenNumber - 1],
symbol: String.fromCodePoint(0x4DC0 + entry.kingWenNumber - 1),
judgmentOriginal: original.judgmentOriginal,
judgmentPlain: plain[0],
lineTextsBottomUp: original.lineTextsBottomUp,
linePlainBottomUp: plain.slice(1),
specialUsageText: original.specialUsageText ?? null,
sourceRefs: [originalSource, plainSource],
};
}),
};
const errors = validateContentPackage(contentPackage);
if (errors.length > 0) {
throw new Error(`Built content package failed validation:\n${errors.join("\n")}`);
}
const outputDirectory = path.join(repositoryRoot, "content", "packages");
await mkdir(outputDirectory, { recursive: true });
const outputPath = path.join(outputDirectory, "hexagram-content.json");
await writeFile(outputPath, `${JSON.stringify(contentPackage, null, 2)}\n`);
process.stdout.write(`wrote ${outputPath}\n`);
+221
View File
@@ -0,0 +1,221 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { kingWenEntries } from "./content-contract.mjs";
import { repositoryRoot } from "./repository-files.mjs";
const execFileAsync = promisify(execFile);
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || "http://127.0.0.1:1080";
const limit = Number.parseInt(process.env.IMPORT_LIMIT ?? "64", 10);
const userAgent = "LingjiContentImport/1.0 (local Zhouyi pipeline; public-domain jingwen only)";
const outputDirectory = path.join(repositoryRoot, "content", "raw");
const outputPath = path.join(outputDirectory, "zhouyi-wikisource-jing.json");
const titles = [
"乾", "坤", "屯", "蒙", "需", "訟", "師", "比",
"小畜", "履", "泰", "否", "同人", "大有", "謙", "豫",
"隨", "蠱", "臨", "觀", "噬嗑", "賁", "剝", "復",
"无妄", "大畜", "頤", "大過", "坎", "離", "咸", "恆",
"遯", "大壯", "晉", "明夷", "家人", "睽", "蹇", "解",
"損", "益", "夬", "姤", "萃", "升", "困", "井",
"革", "鼎", "震", "艮", "漸", "歸妹", "豐", "旅",
"巽", "兌", "渙", "節", "中孚", "小過", "既濟", "未濟",
];
const linePattern = /^(初九|九二|九三|九四|九五|上九|初六|六二|六三|六四|六五|上六|用九|用六)[::,,](.*)$/u;
function stripMarkup(wikitext) {
return wikitext
.replace(/-\{([^}|]+)-\}/gu, "$1")
.replace(/\{\{[^}]*\}\}/gu, "")
.replace(/<[^>]+>/gu, "")
.replace(/'{2,}/gu, "")
.replace(/\[\[File:[^\]]*\]\]/gu, "")
.replace(/\[\[(?:[^\|\]]*\|)?([^\]]+)\]\]/gu, "$1")
.replace(/&nbsp;/gu, " ")
.replace(/\r/gu, "");
}
function parseClassicFields(section) {
const rawLines = section
.split("\n")
.map((line) => line.replace(/^[ *#:]+/u, "").trim())
.filter(Boolean);
let judgment = "";
const lines = [];
let special = null;
for (const rawLine of rawLines) {
const compact = rawLine.replace(/\s+/gu, "");
const hit = linePattern.exec(compact) ?? linePattern.exec(rawLine);
if (hit) {
const label = hit[1];
const text = `${label}:${hit[2].trim()}`;
if (label === "用九" || label === "用六") {
special = text;
} else {
lines.push(text);
}
} else if (lines.length === 0 && special == null) {
const fragment = rawLine.replace(/\s+/gu, "");
judgment = judgment ? `${judgment}${fragment}` : fragment;
}
}
judgment = judgment
.replace(/^(?:周易)?[\u4e00-\u9fff]{1,3}[::]/u, "")
.trim();
if (!judgment) throw new Error("missing judgment");
if (lines.length !== 6) {
throw new Error(`expected 6 lines, got ${lines.length}: ${lines.join(" | ")}`);
}
return { judgmentOriginal: judgment, lineTextsBottomUp: lines, specialUsageText: special };
}
function extractClassicSection(wikitext) {
const normalized = stripMarkup(wikitext).replace(/\u3000/gu, " ");
const classicIndex = normalized.search(/易[經经][::]/u);
if (classicIndex < 0) throw new Error("missing 易經 section");
const afterClassic = normalized.slice(classicIndex);
const stop = afterClassic.search(/\n[ *#]*彖曰[::]/u);
return (stop >= 0 ? afterClassic.slice(0, stop) : afterClassic)
.replace(/^易[經经][::]\s*/u, "")
.trim();
}
function stripHtml(value) {
return value
.replace(/<[^>]+>/gu, "")
.replace(/&quot;/gu, "\"")
.replace(/&amp;/gu, "&")
.replace(/&lt;/gu, "<")
.replace(/&gt;/gu, ">")
.replace(/\n+/gu, "\n")
.trim();
}
async function curlJson(args) {
const { stdout } = await execFileAsync("curl.exe", [
"-sS",
"--fail",
"--retry",
"12",
"--retry-delay",
"20",
"--retry-all-errors",
"--max-time",
"45",
"-x",
proxyUrl,
"-H",
`User-Agent: ${userAgent}`,
"-H",
"Accept: application/json",
...args,
], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
return JSON.parse(stdout);
}
const titleFallbacks = {
恆: ["恒"],
遯: ["遁"],
晉: ["晋"],
大壯: ["大壮"],
歸妹: ["归妹"],
豐: ["丰"],
兌: ["兑"],
渙: ["涣"],
節: ["节"],
既濟: ["既济"],
未濟: ["未济"],
};
async function fetchWikitext(title) {
const candidates = [title, ...(titleFallbacks[title] ?? [])];
for (const candidate of candidates) {
const url = new URL("https://zh.wikisource.org/w/api.php");
url.searchParams.set("action", "parse");
url.searchParams.set("page", `周易/${candidate}`);
url.searchParams.set("prop", "wikitext");
url.searchParams.set("format", "json");
const payload = await curlJson([url.toString()]);
const wikitext = payload?.parse?.wikitext?.["*"];
if (typeof wikitext === "string") return wikitext;
process.stdout.write(`missing ${candidate}, trying fallback\n`);
}
throw new Error(`no wikitext for ${title}`);
}
async function toHansFields(classic) {
const pieces = [
classic.judgmentOriginal,
...classic.lineTextsBottomUp,
...(classic.specialUsageText == null ? [] : [classic.specialUsageText]),
];
const converted = await convertToHans(pieces.join("\n¶\n"));
const parts = converted
.split("¶")
.map((part) => part.replace(/\s+/gu, "").trim())
.filter(Boolean);
if (parts.length !== pieces.length) {
throw new Error(`zh-hans field count ${parts.length} != ${pieces.length}: ${converted}`);
}
return {
judgmentOriginal: parts[0],
lineTextsBottomUp: parts.slice(1, 7),
specialUsageText: classic.specialUsageText == null ? null : parts[7],
};
}
async function convertToHans(text) {
const payload = await curlJson([
"--data-urlencode", "action=parse",
"--data-urlencode", `text=${text}`,
"--data-urlencode", "prop=text",
"--data-urlencode", "variant=zh-hans",
"--data-urlencode", "disablelimitreport=1",
"--data-urlencode", "wrapoutputclass=",
"--data-urlencode", "contentmodel=wikitext",
"--data-urlencode", "format=json",
"https://zh.wikisource.org/w/api.php",
]);
const html = payload?.parse?.text?.["*"];
if (typeof html !== "string") throw new Error("zh-hans conversion returned no text");
return stripHtml(html);
}
const entries = kingWenEntries();
await mkdir(outputDirectory, { recursive: true });
let imported = [];
try {
imported = JSON.parse(await readFile(outputPath, "utf8"));
if (!Array.isArray(imported)) imported = [];
} catch {
imported = [];
}
const done = new Set(imported.map((item) => item.kingWenNumber));
for (const [index, title] of titles.entries()) {
if (index >= limit) break;
const kingWenNumber = index + 1;
if (done.has(kingWenNumber)) {
process.stdout.write(`skip ${kingWenNumber}/64 ${title}\n`);
continue;
}
const wikitext = await fetchWikitext(title);
const traditional = extractClassicSection(wikitext);
const classic = await toHansFields(parseClassicFields(traditional));
imported.push({
kingWenNumber,
wikisourceTitle: title,
sourcePage: `https://zh.wikisource.org/wiki/周易/${title}`,
...entries[index],
...classic,
});
imported.sort((left, right) => left.kingWenNumber - right.kingWenNumber);
await writeFile(outputPath, `${JSON.stringify(imported, null, 2)}\n`);
process.stdout.write(`imported ${kingWenNumber}/64 ${title}\n`);
await new Promise((resolve) => setTimeout(resolve, 1200));
}
process.stdout.write(`wrote ${outputPath} (${imported.length} hexagrams)\n`);
+52
View File
@@ -0,0 +1,52 @@
[CmdletBinding()]
param(
[string]$AdbPath = (Join-Path $env:ANDROID_HOME "platform-tools\adb.exe")
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$gradleWrapper = Join-Path $repositoryRoot "gradlew.bat"
$mainApk = Join-Path $repositoryRoot "app\build\outputs\apk\debug\app-debug.apk"
$testApk = Join-Path $repositoryRoot "app\build\outputs\apk\androidTest\debug\app-debug-androidTest.apk"
$instrumentation = "net.opcapp.flash.test/androidx.test.runner.AndroidJUnitRunner"
if (-not (Test-Path -LiteralPath $AdbPath -PathType Leaf)) {
throw "ADB executable not found: $AdbPath"
}
& $gradleWrapper :app:assembleDebug :app:assembleDebugAndroidTest --no-daemon --console=plain
if ($LASTEXITCODE -ne 0) {
throw "Android app or test APK build failed."
}
$deviceState = (& $AdbPath get-state 2>$null)
if ($LASTEXITCODE -ne 0 -or $deviceState.Trim() -ne "device") {
throw "No authorized Android device is available through: $AdbPath"
}
& $AdbPath install -r $mainApk
if ($LASTEXITCODE -ne 0) {
throw "Main APK installation failed."
}
& $AdbPath install -r $testApk
if ($LASTEXITCODE -ne 0) {
throw "Test APK installation failed."
}
$instrumentationOutput = & $AdbPath shell am instrument -w -r $instrumentation 2>&1
$instrumentationExitCode = $LASTEXITCODE
$instrumentationOutput | Write-Output
$joinedOutput = $instrumentationOutput -join "`n"
if (
$instrumentationExitCode -ne 0 -or
$joinedOutput -match "FAILURES!!!" -or
$joinedOutput -notmatch "OK \(\d+ tests?\)"
) {
throw "Connected Android instrumentation tests failed."
}
Write-Output "Connected Android instrumentation tests passed."
+52 -1
View File
@@ -19,7 +19,9 @@ const kotlinCatalogPath = path.join(
"src",
"main",
"java",
"brainwave",
"net",
"opcapp",
"flash",
"domain",
"casting",
"HexagramCatalog.kt",
@@ -102,3 +104,52 @@ for (const [name, mutate, expected] of negativeCases) {
console.log(
`Content contract verification passed (64 entries cross-checked with Kotlin, ${negativeCases.length} negative fixtures, digest ${digest.slice(0, 12)}…).`,
);
const packagePath = path.join(repositoryRoot, "content", "packages", "hexagram-content.json");
const namesPath = path.join(
repositoryRoot,
"app",
"src",
"main",
"java",
"net",
"opcapp",
"flash",
"core",
"model",
"HexagramNames.kt",
);
const namesSource = await readFile(namesPath, "utf8");
const namesBlock = namesSource.match(/private val names = listOf\(([\s\S]*?)\)/u);
if (!namesBlock) {
throw new Error("HexagramNames.kt name table could not be parsed");
}
const catalogNames = [...namesBlock[1].matchAll(/"([^"]+)"/gu)].map((match) => match[1]);
if (catalogNames.length !== 64) {
throw new Error("HexagramNames.kt must contain 64 names");
}
const published = JSON.parse(await readFile(packagePath, "utf8"));
const publishedErrors = validateContentPackage(published);
if (publishedErrors.length > 0) {
throw new Error(`Published content package failed validation:\n${publishedErrors.join("\n")}`);
}
published.hexagrams.forEach((hexagram) => {
const expectedName = catalogNames[hexagram.kingWenNumber - 1];
const expectedSymbol = String.fromCodePoint(0x4DC0 + hexagram.kingWenNumber - 1);
if (hexagram.name !== expectedName) {
throw new Error(`Published package name for ${hexagram.kingWenNumber} must be ${expectedName}`);
}
if (hexagram.symbol !== expectedSymbol) {
throw new Error(`Published package symbol for ${hexagram.kingWenNumber} must be ${expectedSymbol}`);
}
});
if (published.contentVersion !== "zh-Hans-2026.1") {
throw new Error("Published contentVersion must be zh-Hans-2026.1");
}
if (!published.specialUsageTexts.qian || !published.specialUsageTexts.kun) {
throw new Error("Published package must include Qian 用九 and Kun 用六");
}
console.log(
`Published content package ${published.contentVersion} passed (digest ${contentDigest(published).slice(0, 12)}…).`,
);
+51 -18
View File
@@ -17,19 +17,42 @@ if (manifest.schemaVersion !== 1) failures.push("manifest schemaVersion must be
if (!Array.isArray(manifest.components) || manifest.components.length === 0) {
failures.push("manifest components must not be empty");
}
if (!Array.isArray(manifest.groupPolicies) || manifest.groupPolicies.length === 0) {
failures.push("manifest groupPolicies must not be empty");
}
const allowedLicenses = new Set(["Apache-2.0", "BSD-3-Clause", "EPL-1.0"]);
const ids = new Set();
const coordinates = new Set();
for (const [index, component] of (manifest.components ?? []).entries()) {
const prefix = `components[${index}]`;
for (const field of ["id", "version", "scope", "license", "source"]) {
if (typeof component[field] !== "string" || component[field].trim() === "") {
const validateCommonFields = (entry, prefix) => {
for (const field of ["id", "scope", "license", "source"]) {
if (typeof entry[field] !== "string" || entry[field].trim() === "") {
failures.push(`${prefix}.${field} must be non-blank text`);
}
}
if (ids.has(component.id)) failures.push(`${prefix}.id must be unique`);
ids.add(component.id);
if (ids.has(entry.id)) failures.push(`${prefix}.id must be unique`);
ids.add(entry.id);
if (!allowedLicenses.has(entry.license)) failures.push(`${prefix}.license is not reviewed`);
try {
const url = new URL(entry.source);
if (url.protocol !== "https:") throw new Error("not HTTPS");
} catch {
failures.push(`${prefix}.source must be an absolute HTTPS URL`);
}
if (!documentation.includes(entry.id) || !documentation.includes(entry.license)) {
failures.push(`${prefix} is not represented in docs/dependency-licenses.md`);
}
};
for (const [index, component] of (manifest.components ?? []).entries()) {
const prefix = `components[${index}]`;
validateCommonFields(component, prefix);
if (typeof component.version !== "string" || component.version.trim() === "") {
failures.push(`${prefix}.version must be non-blank text`);
}
if (!documentation.includes(component.version)) {
failures.push(`${prefix}.version is not represented in docs/dependency-licenses.md`);
}
if (component.coordinates !== null) {
if (typeof component.coordinates !== "string" || component.coordinates.split(":").length !== 3) {
failures.push(`${prefix}.coordinates must be group:name:version or null`);
@@ -38,24 +61,31 @@ for (const [index, component] of (manifest.components ?? []).entries()) {
}
coordinates.add(component.coordinates);
}
if (!allowedLicenses.has(component.license)) failures.push(`${prefix}.license is not reviewed`);
try {
const url = new URL(component.source);
if (url.protocol !== "https:") throw new Error("not HTTPS");
} catch {
failures.push(`${prefix}.source must be an absolute HTTPS URL`);
}
if (!documentation.includes(component.id) ||
!documentation.includes(component.version) ||
!documentation.includes(component.license)) {
failures.push(`${prefix} is not represented in docs/dependency-licenses.md`);
}
const groupPrefixes = new Set();
for (const [index, policy] of (manifest.groupPolicies ?? []).entries()) {
const prefix = `groupPolicies[${index}]`;
validateCommonFields(policy, prefix);
if (typeof policy.groupPrefix !== "string" || !/^[a-z][a-z0-9.]+$/u.test(policy.groupPrefix)) {
failures.push(`${prefix}.groupPrefix must be a Maven group prefix`);
} else if (groupPrefixes.has(policy.groupPrefix)) {
failures.push(`${prefix}.groupPrefix must be unique`);
}
groupPrefixes.add(policy.groupPrefix);
}
const version = (name) => new RegExp(`^${name}\\s*=\\s*"([^"]+)"`, "mu").exec(catalog)?.[1];
const expectedVersions = new Map([
["gradle-wrapper", /gradle-([\d.]+)-bin\.zip/u.exec(wrapper)?.[1]],
["android-gradle-plugin", version("agp")],
["kotlin-gradle-plugin", version("kotlin")],
["hilt-gradle-plugin", version("hilt")],
["compose-bom", version("composeBom")],
["navigation-compose", version("navigation")],
["room-runtime", version("room")],
["datastore-preferences", version("dataStore")],
["hilt-android", version("hilt")],
["kotlin-stdlib", version("kotlin")],
["junit4", version("junit")],
]);
@@ -71,5 +101,8 @@ if (failures.length > 0) {
for (const failure of failures) console.error(`- ${failure}`);
process.exitCode = 1;
} else {
console.log(`Dependency-license manifest passed (${manifest.components.length} reviewed components).`);
console.log(
`Dependency-license manifest passed (${manifest.components.length} components, ` +
`${manifest.groupPolicies.length} group policies).`,
);
}
+11 -1
View File
@@ -6,7 +6,17 @@ import {
walkFiles,
} from "./repository-files.mjs";
const domainRoot = path.join(repositoryRoot, "app", "src", "main", "java", "brainwave", "domain");
const domainRoot = path.join(
repositoryRoot,
"app",
"src",
"main",
"java",
"net",
"opcapp",
"flash",
"domain",
);
const forbiddenImports = [
/^android\./u,
/^androidx\./u,
+1
View File
@@ -15,6 +15,7 @@ const checkedExtensions = new Set([
".md",
".mjs",
".properties",
".ps1",
".toml",
".xml",
".yaml",
+61
View File
@@ -0,0 +1,61 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { repositoryRoot } from "./repository-files.mjs";
const manifestPath = path.join(repositoryRoot, "app", "src", "main", "AndroidManifest.xml");
const backupRulesPath = path.join(
repositoryRoot,
"app",
"src",
"main",
"res",
"xml",
"backup_rules.xml",
);
const extractionRulesPath = path.join(
repositoryRoot,
"app",
"src",
"main",
"res",
"xml",
"data_extraction_rules.xml",
);
const [manifest, backupRules, extractionRules] = await Promise.all([
readFile(manifestPath, "utf8"),
readFile(backupRulesPath, "utf8"),
readFile(extractionRulesPath, "utf8"),
]);
const failures = [];
if (!manifest.includes('android:allowBackup="false"')) {
failures.push("Android backup must remain disabled");
}
if (!manifest.includes('android:usesCleartextTraffic="false"')) {
failures.push("cleartext traffic must remain disabled");
}
if (/android\.permission\.INTERNET/u.test(manifest)) {
failures.push("the offline application must not request INTERNET permission");
}
for (const domain of ["root", "file", "database", "sharedpref", "external"]) {
const exclusion = `<exclude domain="${domain}" path="." />`;
if (!backupRules.includes(exclusion)) {
failures.push(`legacy backup rules do not exclude ${domain}`);
}
const extractionOccurrences = extractionRules.split(exclusion).length - 1;
if (extractionOccurrences !== 2) {
failures.push(
`data extraction rules must exclude ${domain} from cloud backup and device transfer`,
);
}
}
if (failures.length > 0) {
console.error("Local-data boundary verification failed:");
for (const failure of failures) console.error(`- ${failure}`);
process.exitCode = 1;
} else {
console.log("Local-data boundary verification passed.");
}
+1 -1
View File
@@ -14,6 +14,6 @@ dependencyResolutionManagement {
}
}
// Repository code name only. This is not the formal product name (TBD-001).
// The repository keeps its original code name; the Android product identity is defined in :app.
rootProject.name = "brainwave"
include(":app")