feat: add installable Lingji Android shell

This commit is contained in:
QiuSW
2026-08-07 18:00:38 +08:00
parent de480ee9bb
commit 730bc528be
43 changed files with 1007 additions and 125 deletions
+4
View File
@@ -23,6 +23,10 @@ jobs:
with: with:
node-version: "22" node-version: "22"
package-manager-cache: false 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 - name: Set up Gradle
uses: gradle/actions/setup-gradle@v6 uses: gradle/actions/setup-gradle@v6
- name: Run local quality gates - name: Run local quality gates
+8 -8
View File
@@ -30,19 +30,19 @@ tools are unavailable or insufficient. State the fallback in the handoff.
## Current build boundary ## Current build boundary
The repository uses `brainwave` only as a code name. Formal product name, The repository keeps `brainwave` only as its code name. The accepted Android
organization-owned application ID, and final `minSdk` remain product decisions. identity is product name `灵机`, namespace/application ID `net.opcapp.flash`, and
Until they are accepted, the `app` project is a Kotlin/JVM domain harness, not a `minSdk` 26. The `app` project is an installable Android application; the app icon
publishable Android application. Do not invent placeholder release identity. and store assets remain product decisions and must not be silently finalized.
## Verification ## Verification
Run from the repository root: Run from the repository root:
```powershell ```powershell
.\gradlew.bat verifyLocal --offline .\gradlew.bat verifyLocal
``` ```
After the Android application plugin is configured, also run the Android gates `verifyLocal` includes Android lint, debug unit tests, APK assembly, repository
listed in `docs/quality-gates.md`. Report skipped device or Android checks checks, and dependency-license coverage. Run `connectedDebugAndroidTest` separately
explicitly; do not imply they passed. when an authorized device is present, and report skipped device checks explicitly.
+118 -15
View File
@@ -1,26 +1,110 @@
import groovy.json.JsonSlurper import groovy.json.JsonSlurper
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { 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}"
}
testOptions {
unitTests.isReturnDefaultValues = true
}
} }
kotlin { kotlin {
jvmToolchain(17) 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 { 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.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) testImplementation(libs.junit)
androidTestImplementation(composeBom)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
} }
tasks.test { tasks.withType<Test>().configureEach {
useJUnit() useJUnit()
testLogging { testLogging {
events("failed", "skipped") events("failed", "skipped")
@@ -30,25 +114,44 @@ tasks.test {
tasks.register("verifyDependencyLicenses") { tasks.register("verifyDependencyLicenses") {
group = "verification" 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 { doLast {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val manifest = JsonSlurper().parse(rootProject.file("config/dependency-licenses.json")) as Map<String, Any> val manifest = JsonSlurper().parse(rootProject.file("config/dependency-licenses.json")) as Map<String, Any>
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val components = manifest.getValue("components") as List<Map<String, Any>> 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 reviewedCoordinates = components.mapNotNull { it["coordinates"] as String? }.toSet()
val resolvedCoordinates = configurations.getByName("testRuntimeClasspath") val resolvedCoordinates = listOf("debugRuntimeClasspath", "debugUnitTestRuntimeClasspath")
.flatMap { configurationName ->
configurations.getByName(configurationName)
.resolvedConfiguration .resolvedConfiguration
.resolvedArtifacts .resolvedArtifacts
.map { artifact -> .mapNotNull { artifact ->
if (artifact.id.componentIdentifier !is org.gradle.api.artifacts.component.ModuleComponentIdentifier) {
return@mapNotNull null
}
val id = artifact.moduleVersion.id val id = artifact.moduleVersion.id
"${id.group}:${id.name}:${id.version}" "${id.group}:${id.name}:${id.version}"
} }
}
.toSet() .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()) { check(missing.isEmpty()) {
"Dependencies missing reviewed license entries: ${missing.sorted().joinToString()}" "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,24 @@
package net.opcapp.flash.app
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
@Test
fun domainFixtureCanBeVerifiedFromHome() {
composeRule.onNodeWithText("灵机").assertIsDisplayed()
composeRule.onNodeWithText("验证起卦核心").performClick()
composeRule.onNodeWithText("复 24 → 坤 2").assertIsDisplayed()
composeRule.onNodeWithText("初爻动").assertIsDisplayed()
}
}
+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,34 @@
package net.opcapp.flash.app
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import net.opcapp.flash.feature.home.DevelopmentHomeScreen
import net.opcapp.flash.feature.result.DomainVerificationScreen
private object Destination {
const val Home = "home"
const val DomainResult = "domain-result"
}
@Composable
fun LingjiApp() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Destination.Home,
) {
composable(Destination.Home) {
DevelopmentHomeScreen(
onVerifyDomain = { navController.navigate(Destination.DomainResult) },
)
}
composable(Destination.DomainResult) {
DomainVerificationScreen(
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,19 @@
package net.opcapp.flash.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import dagger.hilt.android.AndroidEntryPoint
import net.opcapp.flash.core.designsystem.LingjiTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LingjiTheme {
LingjiApp()
}
}
}
}
@@ -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,
)
}
@@ -1,4 +1,4 @@
package brainwave.core.model package net.opcapp.flash.core.model
data class HexagramContent( data class HexagramContent(
val kingWenNumber: Int, val kingWenNumber: Int,
@@ -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
interface HexagramContentRepository { interface HexagramContentRepository {
val contentVersion: String val contentVersion: String
@@ -1,4 +1,4 @@
package brainwave.domain.casting package net.opcapp.flash.domain.casting
data class CastComputation internal constructor( data class CastComputation internal constructor(
val roundsBottomUp: List<CastRound>, val roundsBottomUp: List<CastRound>,
@@ -1,4 +1,4 @@
package brainwave.domain.casting package net.opcapp.flash.domain.casting
data class CastRecordDto( data class CastRecordDto(
val schemaVersion: Int, val schemaVersion: Int,
@@ -1,4 +1,4 @@
package brainwave.domain.casting package net.opcapp.flash.domain.casting
enum class CoinSide(val score: Int) { enum class CoinSide(val score: Int) {
CHARACTER(2), CHARACTER(2),
@@ -1,4 +1,4 @@
package brainwave.domain.casting package net.opcapp.flash.domain.casting
object HexagramCatalog { object HexagramCatalog {
private enum class Trigram(val patternBottomUp: String) { private enum class Trigram(val patternBottomUp: String) {
@@ -0,0 +1,131 @@
package net.opcapp.flash.feature.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
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.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import net.opcapp.flash.R
@Composable
fun DevelopmentHomeScreen(
onVerifyDomain: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.development_build),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(28.dp))
HexagramSeal()
Spacer(Modifier.height(20.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = stringResource(R.string.home_tagline),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(Modifier.height(32.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(12.dp),
) {
Text(
text = stringResource(R.string.core_check_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = stringResource(R.string.core_check_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = onVerifyDomain,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(horizontal = 20.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringResource(R.string.verify_casting_core))
}
}
}
Spacer(Modifier.height(32.dp))
Text(
text = stringResource(R.string.local_data_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun HexagramSeal() {
Surface(
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(18.dp),
modifier = Modifier.height(72.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = 22.dp),
) {
Text(
text = stringResource(R.string.seal_character),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
@@ -0,0 +1,164 @@
package net.opcapp.flash.feature.result
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
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.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.runtime.remember
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.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.domain.casting.CastEngine
import net.opcapp.flash.domain.casting.CastRound
import net.opcapp.flash.domain.casting.LineValue
import net.opcapp.flash.domain.casting.Polarity
@Composable
fun DomainVerificationScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val result = remember {
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),
),
)
}
Surface(
modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 12.dp),
) {
TextButton(
onClick = onBack,
modifier = Modifier.height(48.dp),
) {
Text(stringResource(R.string.back))
}
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.core_check_passed),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(
R.string.fixture_result,
result.primaryHexagramId.value,
result.transformedHexagramId.value,
),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 12.dp),
)
Text(
text = stringResource(R.string.moving_line_result),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(Modifier.height(36.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
HexagramDiagram(
linesBottomUp = result.primaryPatternBottomUp.linesBottomUp,
description = stringResource(R.string.primary_hexagram_description),
)
HexagramDiagram(
linesBottomUp = result.transformedPatternBottomUp.linesBottomUp,
description = stringResource(R.string.transformed_hexagram_description),
)
}
Spacer(Modifier.height(36.dp))
Text(
text = stringResource(R.string.fixture_explanation),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun HexagramDiagram(
linesBottomUp: List<Polarity>,
description: String,
) {
val ink = MaterialTheme.colorScheme.onBackground
val lineWidth = with(LocalDensity.current) { 58.dp.toPx() }
val gap = with(LocalDensity.current) { 12.dp.toPx() }
val strokeWidth = with(LocalDensity.current) { 5.dp.toPx() }
Canvas(
modifier = Modifier
.size(width = 88.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
if (polarity == Polarity.YANG) {
drawLine(
color = ink,
start = Offset(left, y),
end = Offset(right, y),
strokeWidth = strokeWidth,
cap = StrokeCap.Square,
)
} else {
val center = size.width / 2f
drawLine(
color = ink,
start = Offset(left, y),
end = Offset(center - gap / 2f, y),
strokeWidth = strokeWidth,
cap = StrokeCap.Square,
)
drawLine(
color = ink,
start = Offset(center + gap / 2f, y),
end = Offset(right, y),
strokeWidth = strokeWidth,
cap = StrokeCap.Square,
)
}
}
}
}
+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>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">灵机</string>
<string name="development_build">内部构建 · 开发验证页</string>
<string name="home_tagline">本机起卦 · 离线可用</string>
<string name="seal_character">易</string>
<string name="core_check_title">领域核心已接入</string>
<string name="core_check_body">用固定六爻夹具验证卦象、变卦与动爻计算链路。</string>
<string name="verify_casting_core">验证起卦核心</string>
<string name="local_data_notice">当前验证页不写入记录。正式功能将默认保存在本机,只有明确发起 AI 解读时才会联网。</string>
<string name="back">返回</string>
<string name="core_check_passed">计算通过</string>
<string name="fixture_result">复 %1$d → 坤 %2$d</string>
<string name="moving_line_result">初爻动</string>
<string name="fixture_explanation">固定输入为初爻老阳、其余五爻少阴。结果来自纯 Kotlin 起卦核心,不是界面写死的演示值。</string>
<string name="primary_hexagram_description">本卦复,第二十四卦</string>
<string name="transformed_hexagram_description">变卦坤,第二卦</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>
@@ -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
class FakeHexagramContentRepository( class FakeHexagramContentRepository(
override val contentVersion: String = "fixture-only-v1", override val contentVersion: String = "fixture-only-v1",
@@ -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.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.fail import org.junit.Assert.fail
@@ -1,4 +1,4 @@
package brainwave.domain.casting package net.opcapp.flash.domain.casting
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
+7 -1
View File
@@ -2,6 +2,10 @@ import org.gradle.api.tasks.Exec
plugins { plugins {
base 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( fun registerNodeVerificationTask(
@@ -61,7 +65,9 @@ tasks.register("verifyLocal") {
group = LifecycleBasePlugin.VERIFICATION_GROUP group = LifecycleBasePlugin.VERIFICATION_GROUP
description = "Runs every environment-independent local quality gate." description = "Runs every environment-independent local quality gate."
dependsOn( dependsOn(
":app:test", ":app:lintDebug",
":app:testDebugUnitTest",
":app:assembleDebug",
":app:verifyDependencyLicenses", ":app:verifyDependencyLicenses",
verifyDocs, verifyDocs,
scanSecrets, scanSecrets,
+115 -5
View File
@@ -1,7 +1,7 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"reviewedAt": "2026-08-04", "reviewedAt": "2026-08-07",
"scopeNote": "Current JVM runtime/test graph and direct build entry points; Android dependencies are not configured yet.", "scopeNote": "Android debug runtime and debug unit-test runtime graphs, plus direct build entry points. Release notices still require a release-graph review.",
"components": [ "components": [
{ {
"id": "gradle-wrapper", "id": "gradle-wrapper",
@@ -11,6 +11,14 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"source": "https://github.com/gradle/gradle/blob/v8.2.0/LICENSE" "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", "id": "kotlin-gradle-plugin",
"coordinates": null, "coordinates": null,
@@ -19,6 +27,54 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"source": "https://github.com/JetBrains/kotlin/blob/v1.9.20/license/LICENSE.txt" "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", "id": "kotlin-stdlib",
"coordinates": "org.jetbrains.kotlin:kotlin-stdlib:1.9.20", "coordinates": "org.jetbrains.kotlin:kotlin-stdlib:1.9.20",
@@ -29,11 +85,35 @@
}, },
{ {
"id": "jetbrains-annotations", "id": "jetbrains-annotations",
"coordinates": "org.jetbrains:annotations:13.0", "coordinates": "org.jetbrains:annotations:23.0.0",
"version": "13.0", "version": "23.0.0",
"scope": "runtime-transitive", "scope": "runtime-transitive",
"license": "Apache-2.0", "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", "id": "junit4",
@@ -51,5 +131,35 @@
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/LICENSE.txt" "source": "https://github.com/hamcrest/JavaHamcrest/blob/hamcrest-java-1.3/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"
}
] ]
} }
+4 -3
View File
@@ -1,14 +1,14 @@
# Brainwave 项目文档 # Brainwave 项目文档
> 文档状态:方案基线 > 文档状态:方案基线
> 最后核验:2026-08-04 > 最后核验:2026-08-07
> 当前阶段:P-1 v0.3 待视觉复核;P1 纯 Kotlin 领域核心已完成;P0 仓库门禁与 P2 内容契约已部分完成;可发布 Android 壳仍等待正式身份与 `minSdk` 决策 > 当前阶段:正式产品名“灵机”;P0 Android 壳与本地门禁已建立,真机闭环正在执行;P-1 v0.3 待品牌文案刷新和视觉复核;P1 已完成;P2 授权内容未开始
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。 本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
## 项目一句话 ## 项目一句话
Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个人反思工具:用户亲手投掷并录入六次结果,应用在本地确定本卦、之卦和动爻;用户主动点击「解」后,本地内容或 AI 才把既有结果翻译成现代语言,并收束到一项低风险、可撤销的现实行动。 灵机(仓库代号 Brainwave)是一个以《易经》三枚铜币法为文化背景的 Android 个人反思工具:用户亲手投掷并录入六次结果,应用在本地确定本卦、之卦和动爻;用户主动点击「解」后,本地内容或 AI 才把既有结果翻译成现代语言,并收束到一项低风险、可撤销的现实行动。
## 文档地图 ## 文档地图
@@ -69,6 +69,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
已确认: 已确认:
- 目标平台为原生 Android。 - 目标平台为原生 Android。
- 正式产品名为“灵机”,namespace/application ID 为 `net.opcapp.flash`,`minSdk=26`。
- 使用 Kotlin、Jetpack Compose 和单 Activity 架构。 - 使用 Kotlin、Jetpack Compose 和单 Activity 架构。
- 起卦完全在本地完成,AI 不得参与或更改起卦结果。 - 起卦完全在本地完成,AI 不得参与或更改起卦结果。
- 用户真实投币并录入;MVP 不提供随机起卦按钮。 - 用户真实投币并录入;MVP 不提供随机起卦按钮。
+4 -4
View File
@@ -39,11 +39,11 @@ Android 官方建议新应用采用清晰的 UI/数据分层、单向数据流
工程初始化后建议采用: 工程初始化后建议采用:
```text ```text
app/src/main/java/<package>/ app/src/main/java/net/opcapp/flash/
├── app/ ├── app/
│ ├── BrainwaveApplication.kt │ ├── LingjiApplication.kt
│ ├── MainActivity.kt │ ├── MainActivity.kt
│ └── BrainwaveNavHost.kt │ └── LingjiApp.kt
├── core/ ├── core/
│ ├── model/ # 跨层不可变领域模型 │ ├── model/ # 跨层不可变领域模型
│ ├── designsystem/ # 主题、字体、间距、卦象 Canvas │ ├── designsystem/ # 主题、字体、间距、卦象 Canvas
@@ -180,7 +180,7 @@ App → HTTPS backend → model provider
- 禁止直接引入完整“算命 SDK”或无法审计的卦象计算库。 - 禁止直接引入完整“算命 SDK”或无法审计的卦象计算库。
- Compose 依赖使用 BOM 对齐版本。 - Compose 依赖使用 BOM 对齐版本。
- `compileSdk`/`targetSdk` 使用实现时最新稳定且满足商店要求的版本,不在方案文档硬编码会迅速过期的数值。 - `compileSdk`/`targetSdk` 使用实现时最新稳定且满足商店要求的版本,不在方案文档硬编码会迅速过期的数值。
- `minSdk` 默认提案为 26,最终值见[决策记录](decisions.md#未决问题)。 - `minSdk=26` 已由 [ADR-014](decisions.md#adr-014正式-android-身份与最低版本) 确认;提高最低版本必须以依赖要求或覆盖率数据另立决策。
## 11. 多模块化触发条件 ## 11. 多模块化触发条件
+12 -3
View File
@@ -131,13 +131,22 @@
- 后果:`spotlessCheck` 当前是仓库内的无依赖格式兼容入口,不表示已引入 Spotless 插件。Android 壳建立后必须把 lint/debug build 加入 `verifyLocal`;将来若采用维护良好的专用插件,应保留命令兼容或同步更新 CI 与文档。 - 后果:`spotlessCheck` 当前是仓库内的无依赖格式兼容入口,不表示已引入 Spotless 插件。Android 壳建立后必须把 lint/debug build 加入 `verifyLocal`;将来若采用维护良好的专用插件,应保留命令兼容或同步更新 CI 与文档。
- 复审触发:Android 工具链启用、现有脚本无法表达新边界,或专用工具能以可接受成本提供明显更强的检查。 - 复审触发: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 一旦发布不得轻率更换。
## 未决问题 ## 未决问题
| ID | 问题 | 推荐默认 | 阻塞阶段 | | ID | 问题 | 推荐默认 | 阻塞阶段 |
|---|---|---|---| |---|---|---|---|
| TBD-001 | 正式产品名与应用图标 | Brainwave 仅作代码代号 | P0 商店配置 | | TBD-001 | 应用图标与商店素材 | 产品名已由 ADR-014 确认为“灵机”;图标不使用未确认成稿 | P6 商店配置 |
| TBD-002 | application ID | 使用组织所有的反向域名 | P0 |
| TBD-003 | minSdk | 26;创建工程时复核覆盖率和依赖要求 | P0 |
| TBD-004 | 问题是否允许留空 | 允许选择“不写具体内容”,但需显式操作 | P3 | | TBD-004 | 问题是否允许留空 | 允许选择“不写具体内容”,但需显式操作 | P3 |
| TBD-005 | 经典原文、现代白话的版本与授权 | 自有白话 + 可核验公版原文 | P2,发布阻塞 | | TBD-005 | 经典原文、现代白话的版本与授权 | 自有白话 + 可核验公版原文 | P2,发布阻塞 |
| TBD-006 | 乾用九、坤用六是否纳入 MVP | 内容具备时展示 | P2/P3 | | TBD-006 | 乾用九、坤用六是否纳入 MVP | 内容具备时展示 | P2/P3 |
+33 -10
View File
@@ -1,17 +1,40 @@
# 依赖与许可证清单 # 依赖与许可证清单
> 状态:当前 JVM harness 已核验;Android application 依赖尚未配置 > 状态:Android debug runtime 与 debug unit-test runtime 依赖图已建立自动覆盖门禁
> 适用范围:实际解析的 JVM runtime/test graph,以及直接使用的构建入口 > 适用范围:实际解析的 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 | 权威许可来源 | | ID | 版本 | 当前范围 | SPDX | 权威许可来源 |
|---|---:|---|---|---| |---|---:|---|---|---|
| `gradle-wrapper` | 8.2 | 构建入口 | Apache-2.0 | [Gradle 8.2 license](https://github.com/gradle/gradle/blob/v8.2.0/LICENSE) | | `gradle-wrapper` | 8.2 | 构建入口 | Apache-2.0 | [Gradle 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) | | `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-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) | | `kotlin-gradle-plugin` | 1.9.20 | 构建插件 | Apache-2.0 | [Kotlin 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) | | `hilt-gradle-plugin` | 2.48.1 | 构建插件 | Apache-2.0 | [Dagger/Hilt license](https://github.com/google/dagger/blob/dagger-2.48.1/LICENSE.txt) |
| `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) | | `compose-bom` | 2023.10.01 | 运行时版本约束 | Apache-2.0 | [AndroidX license](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.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) | | `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) |
当前没有第三方 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 系统字体和代码绘制的卦象;这句话只描述本提交时的资源图,不构成未来授权。
+1 -1
View File
@@ -5,7 +5,7 @@
本文件定义本项目唯一允许的计算规则。UI 文案、AI 输出和数据源都不能覆盖这些规则。 本文件定义本项目唯一允许的计算规则。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. 术语和类型 ## 1. 术语和类型
+14 -17
View File
@@ -1,7 +1,7 @@
# 本地开发环境基线 # 本地开发环境基线
> 状态:已从当前主机实测 > 状态:已从当前主机实测
> 最后核验:2026-08-04 > 最后核验:2026-08-07
> 适用范围:`D:\OPC\brainwave` 的 Android 开发、构建和设备验证 > 适用范围:`D:\OPC\brainwave` 的 Android 开发、构建和设备验证
本文件记录项目当前可用的本地工具链,不是期望环境清单。后续实现必须先使用这里已经确认的能力;需要升级或安装新工具时,应说明原因并在完成后更新本文件。 本文件记录项目当前可用的本地工具链,不是期望环境清单。后续实现必须先使用这里已经确认的能力;需要升级或安装新工具时,应说明原因并在完成后更新本文件。
@@ -39,7 +39,7 @@
| Node.js / npm | `22.22.1` / `11.12.1` | | Node.js / npm | `22.22.1` / `11.12.1` |
| Google Chrome | `150.0.7871.188`,`C:\Program Files\Google\Chrome\Application\chrome.exe` | | 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)。 Windows 路径可能较长;如果后续依赖缓存或生成代码触发路径长度错误,应优先缩短包/生成目录或评估仓库级长路径配置,并把实际决定写入[决策记录](decisions.md)。
@@ -111,23 +111,21 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
.\gradlew.bat <task> .\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 真机 ## 7. 已连接 Android 真机
检测时存在 1 台已授权的物理设备。为避免持久化设备标识,本文件不记录序列号。 2026-08-07 本轮曾检测到 1 台已授权的物理设备。为避免持久化设备标识,本文件不记录序列号。
| 项目 | 实测值 | | 项目 | 实测值 |
|---|---| |---|---|
| 厂商/型号 | OnePlus `PKG110` | | 厂商/型号 | Samsung `SM-G9700` |
| Android | 16 | | Android | 12 |
| API Level | 36 | | API Level | 31 |
| ABI | `arm64-v8a` | | 物理分辨率 | `1080 × 2280` |
| 物理分辨率 | `1264 × 2780` | | 物理密度 | `480 dpi` |
| 当前覆盖分辨率 | `1080 × 2376` |
| 物理/覆盖密度 | `560 / 480 dpi` |
当前设备可用于 `adb` 安装、手工验收和 `connectedDebugAndroidTest`。测试脚本不得硬编码序列号;设备可能随时断开,纯 JVM 门禁必须独立可运行。 该设备满足 `minSdk=26`,可用于 `adb` 安装、手工验收和 `connectedDebugAndroidTest`。测试脚本不得硬编码序列号;设备可能随时断开,环境独立门禁必须单独可运行。本机 `PATH` 还存在 `D:\Portable\adb\adb.exe`,曾运行旧版 ADB server;项目设备命令应显式使用 `$env:ANDROID_HOME\platform-tools\adb.exe`,避免与 SDK Platform Tools 37.0.0 争用 server。ADB server 重启后若接口消失,应先保持手机解锁并重新确认 USB 调试授权,不应把“Windows 能看到 USB 复合设备”误当作 ADB 已连接。
## 8. 网络与代理状态 ## 8. 网络与代理状态
@@ -146,21 +144,20 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
2. 使用项目自带 Gradle Wrapper,不依赖全局 `gradle` 或 `kotlinc`。 2. 使用项目自带 Gradle Wrapper,不依赖全局 `gradle` 或 `kotlinc`。
3. 首次可构建配置只能依赖已安装的 SDK Platform 34 和 Build Tools 34.0.0;若模板要求更高版本,应先记录差异,再决定调整模板或安装 SDK。 3. 首次可构建配置只能依赖已安装的 SDK Platform 34 和 Build Tools 34.0.0;若模板要求更高版本,应先记录差异,再决定调整模板或安装 SDK。
4. 命令行构建是必须能力,不假设 Android Studio 存在。 4. 命令行构建是必须能力,不假设 Android Studio 存在。
5. 仪器测试优先使用已连接的 API 36、arm64 真机;不假设 Emulator/AVD 存在。 5. 仪器测试优先使用当前可用的 API 31 三星真机;不假设 Emulator/AVD 或固定设备存在。
6. 不自动安装系统级工具、SDK 平台或模拟器;确需新增环境能力时,先说明影响。 6. 不自动安装系统级工具、SDK 平台或模拟器;确需新增环境能力时,先说明影响。
7. 发布时的 `targetSdk` 和商店要求是独立发布门禁,不能因为本机只有 Platform 34 就永久锁定;工具链升级后必须更新本文件和相关 ADR。 7. 发布时的 `targetSdk` 和商店要求是独立发布门禁,不能因为本机只有 Platform 34 就永久锁定;工具链升级后必须更新本文件和相关 ADR。
8. P-1 原型截图使用现有 Node.js 与 Chrome DevTools 协议生成,不额外安装前端依赖;命令见[移动端交互原型](prototype.md#3-运行方式)。 8. P-1 原型截图使用现有 Node.js 与 Chrome DevTools 协议生成,不额外安装前端依赖;命令见[移动端交互原型](prototype.md#3-运行方式)。
## 10. 已知缺口 ## 10. 已知缺口
- 可安装的 Android application 壳尚未创建;正式名称、组织所有的 application ID 与最终 `minSdk` 仍待确认。
- Android Studio 未安装。 - Android Studio 未安装。
- Android Emulator、system image 和 AVD 不可用。 - Android Emulator、system image 和 AVD 不可用。
- 本机只确认安装了 Android Platform 34 / Build Tools 34.0.0。 - 本机只确认安装了 Android Platform 34 / Build Tools 34.0.0。
- Compose、Hilt、Room、DataStore 与 Navigation 的 Maven 依赖尚未完整离线验证。 - `PATH` 中旧便携 ADB 与 SDK ADB 可能争用 server;设备接口消失时需要手机端重新授权或重新连接。
- 代理已配置,但外部仓库和 Android CLI 网络连通性尚未验证。 - 代理已配置;本轮 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 壳均已建立;`assembleDebug`、13 个 debug 单元测试、`lintDebug` 与测试 APK 编译已在 JDK 17 上通过。`verifyLocal` 现已包含 Android lint 与 debug APK,设备测试仍按环境单独执行。
这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。 这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
+8 -8
View File
@@ -1,6 +1,6 @@
# 分阶段实施计划 # 分阶段实施计划
> 状态:P-1 v0.3 待视觉复核;P0 仓库门禁已建立但 Android 壳受 TBD-001~003 阻塞;P1 已完成并通过穷举测试;P2 内容契约已建立但授权内容未开始 > 状态:P-1 v0.3 待视觉复核;P0 Android 壳与本地门禁已完成,真机闭环正在执行;P1 已完成并通过穷举测试;P2 内容契约已建立但授权内容未开始
> 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI > 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI
## 1. 依赖图 ## 1. 依赖图
@@ -47,11 +47,11 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
任务: 任务:
- [x] 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。 - [x] 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。
- [ ] 基于 Google `android/architecture-templates` 的 `base` 分支初始化 Android 壳;模板定制需要 TBD-002 的正式 application ID,不能用临时发布身份替代。 - [x] 参照 Google `android/architecture-templates` 的 `base` 分支建立单模块 Android 壳和分层边界。
- [ ] 确认正式应用名称、package/application ID、minSdk(TBD-001~003)。 - [x] 确认正式应用名称“灵机”、namespace/application ID `net.opcapp.flash`、`minSdk=26`(ADR-014)。
- [ ] 配置 Compose、Material 3、Hilt、Room、DataStore 和 Navigation;Version Catalog 已先用于 JVM harness。 - [x] 配置 Compose、Material 3、Hilt、Room、DataStore 和 Navigation,并由 Version Catalog 固定兼容版本。
- [ ] 配置 Gradle Wrapper、格式化、lint、单元测试和 CI:Wrapper、无依赖格式门禁、JVM 单测与 CI 已完成;Android lint 要等 application 插件启用。 - [x] 配置 Gradle Wrapper、格式、Android lint、单元测试、debug APK、依赖许可证门禁和 CI 同入口验证。
- [ ] 建立 [系统架构](architecture.md)中的包结构和空 feature 边界:`domain/casting` 已落地,其余随 Android 壳建立。 - [x] 建立 [系统架构](architecture.md)中的 `app`、`core/designsystem`、`domain`、`data`、`feature/home` 与 `feature/result` 边界;未实施 feature 不创建空包。
- [x] 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。 - [x] 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。
- [x] 增加 `verifyLocal` 聚合任务、文档链接、领域边界、内容契约与基础 secret scan。 - [x] 增加 `verifyLocal` 聚合任务、文档链接、领域边界、内容契约与基础 secret scan。
@@ -59,7 +59,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- Windows 上一条命令可执行格式、lint、单测和 debug 构建。 - Windows 上一条命令可执行格式、lint、单测和 debug 构建。
- CI 使用同一组命令。 - CI 使用同一组命令。
- 空应用可在模拟器启动,导航到占位欢迎页。 - 开发验证应用可在授权真机启动,并从首页导航到已知领域夹具结果页。
- 没有生产服务密钥或真实内容。 - 没有生产服务密钥或真实内容。
## 4. P1:领域核心 ## 4. P1:领域核心
@@ -82,7 +82,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
- 测试无随机、无网络、无系统时间依赖。 - 测试无随机、无网络、无系统时间依赖。
- `CastEngine` API 经评审后冻结为 `coin-v1`。 - `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:内容数据管线 ## 5. P2:内容数据管线
+1 -1
View File
@@ -8,7 +8,7 @@
在初始化 Android 工程前,先用可点击原型确认最容易返工的产品决策:信息层级、六次录入方式、结果阅读顺序、AI 同意门和东方文化表达。原型确认后,P3 的 Compose 页面应复用这里的设计令牌和状态契约,而不是重新发明流程。 在初始化 Android 工程前,先用可点击原型确认最容易返工的产品决策:信息层级、六次录入方式、结果阅读顺序、AI 同意门和东方文化表达。原型确认后,P3 的 Compose 页面应复用这里的设计令牌和状态契约,而不是重新发明流程。
本轮使用“一问”作为视觉工作名。它不构成正式命名决定;正式名称、应用图标和商店素材仍是 [TBD-001](decisions.md#未决问题)。 v0.3 截图仍保留当时的视觉工作名“一问”,不得当作当前产品文案。正式产品名已由 [ADR-014](decisions.md#adr-014正式-android-身份与最低版本) 确认为“灵机”;应用图标与商店素材仍见 [TBD-001](decisions.md#未决问题),P3 固化正式页面前须刷新原型品牌文案。
## 2. 交付范围 ## 2. 交付范围
+6 -11
View File
@@ -1,6 +1,6 @@
# 质量门禁与验证策略 # 质量门禁与验证策略
> 状态:JVM/harness 门禁已启用;Android lint、构建、UI 与设备门禁等待 Android application 壳 > 状态:Android 本地门禁已启用;设备门禁在授权真机存在时单独执行
> 原则:完成必须有可重复证据,不能以“代码看起来正确”代替验证 > 原则:完成必须有可重复证据,不能以“代码看起来正确”代替验证
## 1. 反馈循环 ## 1. 反馈循环
@@ -21,26 +21,21 @@
```powershell ```powershell
.\gradlew.bat spotlessCheck --offline .\gradlew.bat spotlessCheck --offline
.\gradlew.bat :app:test --offline .\gradlew.bat :app:testDebugUnitTest --offline
.\gradlew.bat verifyContentContract --offline .\gradlew.bat verifyContentContract --offline
.\gradlew.bat verifyLocal --offline .\gradlew.bat verifyLocal
``` ```
`verifyLocal` 当前聚合无依赖格式检查、10 个领域测试、3 个内容 repository 测试、已解析 JVM 依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan 和原型 JavaScript 语法检查。CI 执行同一个聚合任务。 `verifyLocal` 聚合无依赖格式检查、10 个领域测试、3 个内容 repository 测试、Android lint、debug APK、已解析 Android 调试/单测依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan 和原型 JavaScript 语法检查。CI 执行同一个聚合任务。首次解析 Android 依赖时不能强制 `--offline`;缓存完成后可用离线模式复核。
Android application 插件配置后,Windows 环境还必须提供: 授权真机存在时,Windows 环境还必须执行:
```powershell ```powershell
.\gradlew.bat spotlessCheck .\gradlew.bat spotlessCheck
.\gradlew.bat lintDebug
.\gradlew.bat testDebugUnitTest
.\gradlew.bat assembleDebug
.\gradlew.bat connectedDebugAndroidTest .\gradlew.bat connectedDebugAndroidTest
``` ```
若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。`connectedDebugAndroidTest` 需要模拟器或设备,应与纯 JVM 快速门禁分开。 若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。`connectedDebugAndroidTest` 需要模拟器或设备,继续与环境独立的 `verifyLocal` 分开;`verifyLocal` 通过证明 APK 已构建,但不等于真机测试已经通过。
到那时必须把 Android lint 和 debug build 加入现有 `verifyLocal`,使代理仍不必猜测验证组合。在完成这一步之前,`verifyLocal` 通过只证明 JVM 与仓库门禁,不代表 APK 已构建或真机测试已通过。
## 3. 测试层次 ## 3. 测试层次
+3
View File
@@ -0,0 +1,3 @@
android.useAndroidX=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+36 -1
View File
@@ -1,9 +1,44 @@
[versions] [versions]
agp = "8.2.0"
kotlin = "1.9.20" 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" junit = "4.13.2"
androidxTestExt = "1.1.5"
androidxTestRunner = "1.5.2"
[libraries] [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-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-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" } 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] [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" }
+3 -1
View File
@@ -19,7 +19,9 @@ const kotlinCatalogPath = path.join(
"src", "src",
"main", "main",
"java", "java",
"brainwave", "net",
"opcapp",
"flash",
"domain", "domain",
"casting", "casting",
"HexagramCatalog.kt", "HexagramCatalog.kt",
+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) { if (!Array.isArray(manifest.components) || manifest.components.length === 0) {
failures.push("manifest components must not be empty"); 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 allowedLicenses = new Set(["Apache-2.0", "BSD-3-Clause", "EPL-1.0"]);
const ids = new Set(); const ids = new Set();
const coordinates = new Set(); const coordinates = new Set();
for (const [index, component] of (manifest.components ?? []).entries()) { const validateCommonFields = (entry, prefix) => {
const prefix = `components[${index}]`; for (const field of ["id", "scope", "license", "source"]) {
for (const field of ["id", "version", "scope", "license", "source"]) { if (typeof entry[field] !== "string" || entry[field].trim() === "") {
if (typeof component[field] !== "string" || component[field].trim() === "") {
failures.push(`${prefix}.${field} must be non-blank text`); failures.push(`${prefix}.${field} must be non-blank text`);
} }
} }
if (ids.has(component.id)) failures.push(`${prefix}.id must be unique`); if (ids.has(entry.id)) failures.push(`${prefix}.id must be unique`);
ids.add(component.id); 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 (component.coordinates !== null) {
if (typeof component.coordinates !== "string" || component.coordinates.split(":").length !== 3) { if (typeof component.coordinates !== "string" || component.coordinates.split(":").length !== 3) {
failures.push(`${prefix}.coordinates must be group:name:version or null`); 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); coordinates.add(component.coordinates);
} }
if (!allowedLicenses.has(component.license)) failures.push(`${prefix}.license is not reviewed`); }
try {
const url = new URL(component.source); const groupPrefixes = new Set();
if (url.protocol !== "https:") throw new Error("not HTTPS"); for (const [index, policy] of (manifest.groupPolicies ?? []).entries()) {
} catch { const prefix = `groupPolicies[${index}]`;
failures.push(`${prefix}.source must be an absolute HTTPS URL`); validateCommonFields(policy, prefix);
} if (typeof policy.groupPrefix !== "string" || !/^[a-z][a-z0-9.]+$/u.test(policy.groupPrefix)) {
if (!documentation.includes(component.id) || failures.push(`${prefix}.groupPrefix must be a Maven group prefix`);
!documentation.includes(component.version) || } else if (groupPrefixes.has(policy.groupPrefix)) {
!documentation.includes(component.license)) { failures.push(`${prefix}.groupPrefix must be unique`);
failures.push(`${prefix} is not represented in docs/dependency-licenses.md`);
} }
groupPrefixes.add(policy.groupPrefix);
} }
const version = (name) => new RegExp(`^${name}\\s*=\\s*"([^"]+)"`, "mu").exec(catalog)?.[1]; const version = (name) => new RegExp(`^${name}\\s*=\\s*"([^"]+)"`, "mu").exec(catalog)?.[1];
const expectedVersions = new Map([ const expectedVersions = new Map([
["gradle-wrapper", /gradle-([\d.]+)-bin\.zip/u.exec(wrapper)?.[1]], ["gradle-wrapper", /gradle-([\d.]+)-bin\.zip/u.exec(wrapper)?.[1]],
["android-gradle-plugin", version("agp")],
["kotlin-gradle-plugin", version("kotlin")], ["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")], ["kotlin-stdlib", version("kotlin")],
["junit4", version("junit")], ["junit4", version("junit")],
]); ]);
@@ -71,5 +101,8 @@ if (failures.length > 0) {
for (const failure of failures) console.error(`- ${failure}`); for (const failure of failures) console.error(`- ${failure}`);
process.exitCode = 1; process.exitCode = 1;
} else { } 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, walkFiles,
} from "./repository-files.mjs"; } 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 = [ const forbiddenImports = [
/^android\./u, /^android\./u,
/^androidx\./u, /^androidx\./u,
+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" rootProject.name = "brainwave"
include(":app") include(":app")