feat: add installable Lingji Android shell
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+122
-19
@@ -1,26 +1,110 @@
|
||||
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}"
|
||||
}
|
||||
|
||||
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.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)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnit()
|
||||
testLogging {
|
||||
events("failed", "skipped")
|
||||
@@ -30,25 +114,44 @@ 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")
|
||||
.resolvedConfiguration
|
||||
.resolvedArtifacts
|
||||
.map { artifact ->
|
||||
val id = artifact.moduleVersion.id
|
||||
"${id.group}:${id.name}:${id.version}"
|
||||
val resolvedCoordinates = listOf("debugRuntimeClasspath", "debugUnitTestRuntimeClasspath")
|
||||
.flatMap { configurationName ->
|
||||
configurations.getByName(configurationName)
|
||||
.resolvedConfiguration
|
||||
.resolvedArtifacts
|
||||
.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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package brainwave.core.model
|
||||
package net.opcapp.flash.core.model
|
||||
|
||||
data class HexagramContent(
|
||||
val kingWenNumber: Int,
|
||||
+2
-2
@@ -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 {
|
||||
val contentVersion: String
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package brainwave.domain.casting
|
||||
package net.opcapp.flash.domain.casting
|
||||
|
||||
data class CastComputation internal constructor(
|
||||
val roundsBottomUp: List<CastRound>,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package brainwave.domain.casting
|
||||
package net.opcapp.flash.domain.casting
|
||||
|
||||
data class CastRecordDto(
|
||||
val schemaVersion: Int,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package brainwave.domain.casting
|
||||
package net.opcapp.flash.domain.casting
|
||||
|
||||
enum class CoinSide(val score: Int) {
|
||||
CHARACTER(2),
|
||||
+1
-1
@@ -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,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+2
-2
@@ -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(
|
||||
override val contentVersion: String = "fixture-only-v1",
|
||||
+2
-2
@@ -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
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package brainwave.domain.casting
|
||||
package net.opcapp.flash.domain.casting
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
+7
-1
@@ -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(
|
||||
@@ -61,7 +65,9 @@ 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,
|
||||
|
||||
@@ -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-07",
|
||||
"scopeNote": "Android debug runtime and debug unit-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",
|
||||
@@ -51,5 +131,35 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"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
@@ -1,14 +1,14 @@
|
||||
# Brainwave 项目文档
|
||||
|
||||
> 文档状态:方案基线
|
||||
> 最后核验:2026-08-04
|
||||
> 当前阶段:P-1 v0.3 待视觉复核;P1 纯 Kotlin 领域核心已完成;P0 仓库门禁与 P2 内容契约已部分完成;可发布 Android 壳仍等待正式身份与 `minSdk` 决策
|
||||
> 最后核验:2026-08-07
|
||||
> 当前阶段:正式产品名“灵机”;P0 Android 壳与本地门禁已建立,真机闭环正在执行;P-1 v0.3 待品牌文案刷新和视觉复核;P1 已完成;P2 授权内容未开始
|
||||
|
||||
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
|
||||
|
||||
## 项目一句话
|
||||
|
||||
Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个人反思工具:用户亲手投掷并录入六次结果,应用在本地确定本卦、之卦和动爻;用户主动点击「解」后,本地内容或 AI 才把既有结果翻译成现代语言,并收束到一项低风险、可撤销的现实行动。
|
||||
灵机(仓库代号 Brainwave)是一个以《易经》三枚铜币法为文化背景的 Android 个人反思工具:用户亲手投掷并录入六次结果,应用在本地确定本卦、之卦和动爻;用户主动点击「解」后,本地内容或 AI 才把既有结果翻译成现代语言,并收束到一项低风险、可撤销的现实行动。
|
||||
|
||||
## 文档地图
|
||||
|
||||
@@ -69,6 +69,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
|
||||
已确认:
|
||||
|
||||
- 目标平台为原生 Android。
|
||||
- 正式产品名为“灵机”,namespace/application ID 为 `net.opcapp.flash`,`minSdk=26`。
|
||||
- 使用 Kotlin、Jetpack Compose 和单 Activity 架构。
|
||||
- 起卦完全在本地完成,AI 不得参与或更改起卦结果。
|
||||
- 用户真实投币并录入;MVP 不提供随机起卦按钮。
|
||||
|
||||
@@ -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
|
||||
@@ -180,7 +180,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. 多模块化触发条件
|
||||
|
||||
|
||||
+12
-3
@@ -131,13 +131,22 @@
|
||||
- 后果:`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 一旦发布不得轻率更换。
|
||||
|
||||
## 未决问题
|
||||
|
||||
| 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 |
|
||||
|
||||
+33
-10
@@ -1,17 +1,40 @@
|
||||
# 依赖与许可证清单
|
||||
|
||||
> 状态:当前 JVM harness 已核验;Android application 依赖尚未配置
|
||||
> 适用范围:实际解析的 JVM runtime/test graph,以及直接使用的构建入口
|
||||
> 状态:Android debug runtime 与 debug unit-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) |
|
||||
|
||||
当前没有第三方 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 系统字体和代码绘制的卦象;这句话只描述本提交时的资源图,不构成未来授权。
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
# 本地开发环境基线
|
||||
|
||||
> 状态:已从当前主机实测
|
||||
> 最后核验:2026-08-04
|
||||
> 最后核验:2026-08-07
|
||||
> 适用范围:`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` 安装、手工验收和 `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. 网络与代理状态
|
||||
|
||||
@@ -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 与 SDK ADB 可能争用 server;设备接口消失时需要手机端重新授权或重新连接。
|
||||
- 代理已配置;本轮 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)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
## 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,7 +59,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原
|
||||
|
||||
- Windows 上一条命令可执行格式、lint、单测和 debug 构建。
|
||||
- CI 使用同一组命令。
|
||||
- 空应用可在模拟器启动,导航到占位欢迎页。
|
||||
- 开发验证应用可在授权真机启动,并从首页导航到已知领域夹具结果页。
|
||||
- 没有生产服务密钥或真实内容。
|
||||
|
||||
## 4. P1:领域核心
|
||||
@@ -82,7 +82,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:内容数据管线
|
||||
|
||||
|
||||
+1
-1
@@ -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. 交付范围
|
||||
|
||||
|
||||
+6
-11
@@ -1,6 +1,6 @@
|
||||
# 质量门禁与验证策略
|
||||
|
||||
> 状态:JVM/harness 门禁已启用;Android lint、构建、UI 与设备门禁等待 Android application 壳
|
||||
> 状态:Android 本地门禁已启用;设备门禁在授权真机存在时单独执行
|
||||
> 原则:完成必须有可重复证据,不能以“代码看起来正确”代替验证
|
||||
|
||||
## 1. 反馈循环
|
||||
@@ -21,26 +21,21 @@
|
||||
|
||||
```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` 聚合无依赖格式检查、10 个领域测试、3 个内容 repository 测试、Android lint、debug APK、已解析 Android 调试/单测依赖许可证、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan 和原型 JavaScript 语法检查。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 快速门禁分开。
|
||||
|
||||
到那时必须把 Android lint 和 debug build 加入现有 `verifyLocal`,使代理仍不必猜测验证组合。在完成这一步之前,`verifyLocal` 通过只证明 JVM 与仓库门禁,不代表 APK 已构建或真机测试已通过。
|
||||
若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。`connectedDebugAndroidTest` 需要模拟器或设备,继续与环境独立的 `verifyLocal` 分开;`verifyLocal` 通过证明 APK 已构建,但不等于真机测试已经通过。
|
||||
|
||||
## 3. 测试层次
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
@@ -1,9 +1,44 @@
|
||||
[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-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" }
|
||||
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" }
|
||||
|
||||
@@ -19,7 +19,9 @@ const kotlinCatalogPath = path.join(
|
||||
"src",
|
||||
"main",
|
||||
"java",
|
||||
"brainwave",
|
||||
"net",
|
||||
"opcapp",
|
||||
"flash",
|
||||
"domain",
|
||||
"casting",
|
||||
"HexagramCatalog.kt",
|
||||
|
||||
@@ -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).`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
-1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user