finish skills
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
---
|
||||
name: compose-expert
|
||||
description: Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector icons, or determining whether to share UI in commonMain vs keep platform-specific. Delegates navigation to android-expert/desktop-expert. Complements kotlin-expert (handles Kotlin language aspects of state/annotations).
|
||||
---
|
||||
|
||||
# Compose Multiplatform Expert
|
||||
|
||||
Visual UI patterns for sharing composables across Android and Desktop.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Creating or refactoring shared UI components
|
||||
- Deciding whether to share UI in `commonMain` or keep platform-specific
|
||||
- Building custom ImageVector icons (robohash pattern)
|
||||
- State management: remember, derivedStateOf, produceState
|
||||
- Recomposition optimization: visual usage of @Stable/@Immutable
|
||||
- Material3 theming and styling
|
||||
- Performance: lazy lists, image loading
|
||||
|
||||
**Delegate to other skills:**
|
||||
- Navigation structure → `android-expert`, `desktop-expert`
|
||||
- Kotlin state patterns (StateFlow, sealed classes) → `kotlin-expert`
|
||||
- Build configuration → `gradle-expert`
|
||||
|
||||
## Philosophy: Share by Default
|
||||
|
||||
**Default to `commons/commonMain`** unless platform experts indicate otherwise.
|
||||
|
||||
### Always Share
|
||||
|
||||
- **UI components**: Buttons, cards, lists, dialogs, inputs
|
||||
- **State visualization**: Loading, empty, error states
|
||||
- **Custom icons**: ImageVector assets (robohash, custom paths)
|
||||
- **Theme utilities**: Color calculations, style helpers
|
||||
- **Material3 components**: Any UI using Material primitives
|
||||
|
||||
### Keep Platform-Specific
|
||||
|
||||
- **Navigation structure**: Bottom nav (Android) vs Sidebar (Desktop)
|
||||
- **Screen layouts**: Platform-specific scaffolding
|
||||
- **System integrations**: File pickers, notifications, share sheets
|
||||
- **Platform UX**: Gestures, keyboard shortcuts, window management
|
||||
|
||||
### Decision Framework
|
||||
|
||||
1. **Uses only Material3 primitives?** → Share in `commonMain`
|
||||
2. **Requires platform system APIs?** → Platform-specific
|
||||
3. **Pure visual component without navigation?** → Share in `commonMain`
|
||||
4. **Needs platform UX patterns?** → Ask `android-expert` or `desktop-expert`
|
||||
|
||||
If uncertain, **default to sharing** - easier to split later than merge.
|
||||
|
||||
## Shared Composable Anatomy
|
||||
|
||||
### Structure
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SharedComponent(
|
||||
// State parameters (read-only)
|
||||
data: DataClass,
|
||||
isLoading: Boolean,
|
||||
// Event parameters (write-only)
|
||||
onAction: () -> Unit,
|
||||
// Visual parameters
|
||||
modifier: Modifier = Modifier,
|
||||
// Optional customization
|
||||
colors: ComponentColors = ComponentDefaults.colors()
|
||||
) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: State down, events up
|
||||
- Parameters above modifier = required state/events
|
||||
- `modifier` parameter = layout control
|
||||
- Parameters below modifier = optional customization
|
||||
|
||||
### Example: AddButton
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Add",
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
shape = ActionButtonShape,
|
||||
contentPadding = ActionButtonPadding
|
||||
) {
|
||||
Text(text = text, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared constants for consistency
|
||||
val ActionButtonShape = RoundedCornerShape(20.dp)
|
||||
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
|
||||
```
|
||||
|
||||
**Why this works on all platforms:**
|
||||
- Material3 primitives (OutlinedButton, Text)
|
||||
- No platform APIs
|
||||
- Configurable through parameters
|
||||
- Consistent styling via shared constants
|
||||
|
||||
## State Management Patterns
|
||||
|
||||
### remember - Cache Across Recompositions
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ExpandableCard() {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column {
|
||||
IconButton(onClick = { isExpanded = !isExpanded }) {
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand"
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
Text("Expanded content...")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual pattern**: Toggle button → state changes → UI expands/collapses
|
||||
**Use for**: Simple UI state (toggles, counters, text input)
|
||||
|
||||
### derivedStateOf - Optimize Frequent Changes
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ScrollToTopButton(listState: LazyListState) {
|
||||
// Only recomposes when showButton changes, not every scroll pixel
|
||||
val showButton by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0
|
||||
}
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
FloatingActionButton(onClick = { /* scroll to top */ }) {
|
||||
Icon(Icons.Default.ArrowUpward, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → Button visibility
|
||||
**Use for**: Input changes frequently, derived result changes rarely
|
||||
**Performance**: Prevents recomposition on every scroll event
|
||||
|
||||
### produceState - Async to Compose State
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoadUserProfile(userId: String): State<User?> {
|
||||
return produceState<User?>(initialValue = null, userId) {
|
||||
value = repository.fetchUser(userId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(userId: String) {
|
||||
val user by LoadUserProfile(userId)
|
||||
|
||||
when (user) {
|
||||
null -> LoadingState("Loading profile...")
|
||||
else -> ProfileCard(user!!)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual pattern**: Async operation → state updates → UI reflects changes
|
||||
**Use for**: Convert Flow, LiveData, callbacks into Compose state
|
||||
**Lifecycle**: Coroutine cancelled when composable leaves composition
|
||||
|
||||
For Kotlin-specific state patterns (StateFlow, sealed classes), see `kotlin-expert`.
|
||||
|
||||
## State Hoisting
|
||||
|
||||
Move state up to make composables reusable:
|
||||
|
||||
```kotlin
|
||||
// ❌ Stateful - hard to test, can't control externally
|
||||
@Composable
|
||||
fun BadSearchBar() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
TextField(value = query, onValueChange = { query = it })
|
||||
}
|
||||
|
||||
// ✅ Stateless - reusable, testable
|
||||
@Composable
|
||||
fun GoodSearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchScreen() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
Column {
|
||||
GoodSearchBar(query = query, onQueryChange = { query = it })
|
||||
SearchResults(query = query)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Principle**: State up, events down
|
||||
- State: `query: String` (read-only parameter)
|
||||
- Events: `onQueryChange: (String) -> Unit` (callback parameter)
|
||||
|
||||
## Recomposition Optimization
|
||||
|
||||
### Visual Usage of @Immutable
|
||||
|
||||
Use @Immutable on data classes passed to composables:
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class UserProfile(val name: String, val avatar: String)
|
||||
|
||||
@Composable
|
||||
fun ProfileCard(profile: UserProfile) {
|
||||
// Only recomposes when profile instance changes
|
||||
Row {
|
||||
RobohashImage(robot = profile.avatar)
|
||||
Text(profile.name, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual effect**: Prevents recomposition when parent recomposes with same data
|
||||
**Pattern**: Mark parameter data classes as @Immutable
|
||||
**Note**: For Kotlin language details on @Immutable, see `kotlin-expert`
|
||||
|
||||
### Stable Parameters
|
||||
|
||||
```kotlin
|
||||
// ✅ Stable - won't trigger recomposition unless colors instance changes
|
||||
@Composable
|
||||
fun ThemedCard(
|
||||
content: String,
|
||||
colors: CardColors = CardDefaults.colors(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(colors = colors, modifier = modifier) {
|
||||
Text(content)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For @Stable annotation details, see `kotlin-expert`.
|
||||
|
||||
## Material3 Theming
|
||||
|
||||
All shared composables use Material3 for consistency:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ThemedComponent() {
|
||||
val bg = MaterialTheme.colorScheme.background
|
||||
val fg = MaterialTheme.colorScheme.onBackground
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(bg)
|
||||
) {
|
||||
Text(
|
||||
"Title",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = fg
|
||||
)
|
||||
Button(
|
||||
onClick = { /* ... */ },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = primary)
|
||||
) {
|
||||
Text("Action")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Principles:**
|
||||
- Colors: `MaterialTheme.colorScheme.*`
|
||||
- Typography: `MaterialTheme.typography.*`
|
||||
- Shapes: `MaterialTheme.shapes.*`
|
||||
|
||||
### Theme Detection
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun isLightTheme(): Boolean {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemedIcon() {
|
||||
val isDark = !isLightTheme()
|
||||
val tint = if (isDark) Color.White else Color.Black
|
||||
Icon(Icons.Default.Face, null, tint = tint)
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Icons: ImageVector Pattern
|
||||
|
||||
Amethyst uses ImageVector for multiplatform icons.
|
||||
|
||||
### roboBuilder DSL
|
||||
|
||||
```kotlin
|
||||
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = "Robohash",
|
||||
defaultWidth = 300.dp,
|
||||
defaultHeight = 300.dp,
|
||||
viewportWidth = 300f,
|
||||
viewportHeight = 300f
|
||||
).apply(block).build()
|
||||
}
|
||||
```
|
||||
|
||||
### Building Icons
|
||||
|
||||
```kotlin
|
||||
fun customIcon(fgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
|
||||
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
|
||||
builder.addPath(pathData3, fill = Black, fillAlpha = 0.2f)
|
||||
}
|
||||
|
||||
private val pathData1 = PathData {
|
||||
moveTo(144.5f, 87.5f)
|
||||
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
|
||||
lineToRelative(16.0f, 16.0f)
|
||||
close()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomIcon() {
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
customIcon(SolidColor(Color.Blue), this)
|
||||
}
|
||||
),
|
||||
contentDescription = "Custom icon"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Why ImageVector?**
|
||||
- Pure Kotlin, no XML
|
||||
- Works on Android, Desktop, iOS
|
||||
- GPU-accelerated
|
||||
- Type-safe
|
||||
|
||||
### Caching Pattern
|
||||
|
||||
```kotlin
|
||||
object CustomIcons {
|
||||
private val cache = mutableMapOf<String, ImageVector>()
|
||||
|
||||
fun get(key: String): ImageVector {
|
||||
return cache.getOrPut(key) {
|
||||
buildIcon(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CachedIcon(key: String) {
|
||||
Image(imageVector = CustomIcons.get(key), contentDescription = null)
|
||||
}
|
||||
```
|
||||
|
||||
For detailed icon patterns, see `references/icon-assets.md`.
|
||||
|
||||
## Common Visual Patterns
|
||||
|
||||
### State Visualization
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DataScreen(uiState: UiState) {
|
||||
when (uiState) {
|
||||
is UiState.Loading -> LoadingState("Loading...")
|
||||
is UiState.Empty -> EmptyState(
|
||||
title = "No data",
|
||||
onRefresh = { /* refresh */ }
|
||||
)
|
||||
is UiState.Error -> ErrorState(
|
||||
message = uiState.message,
|
||||
onRetry = { /* retry */ }
|
||||
)
|
||||
is UiState.Success -> ContentList(uiState.items)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Components** (all in `commons/commonMain`):
|
||||
- `LoadingState` - Progress indicator + message
|
||||
- `EmptyState` - Empty message + optional refresh button
|
||||
- `ErrorState` - Error message + optional retry button
|
||||
|
||||
### Relay Status (Amethyst Pattern)
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayStatusIndicator(connectedCount: Int) {
|
||||
val statusColor = when {
|
||||
connectedCount == 0 -> RelayStatusColors.Disconnected
|
||||
connectedCount < 3 -> RelayStatusColors.Connecting
|
||||
else -> RelayStatusColors.Connected
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(
|
||||
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual mapping**:
|
||||
- 0 relays → Red + X icon
|
||||
- 1-2 relays → Yellow + Check icon
|
||||
- 3+ relays → Green + Check icon
|
||||
|
||||
### Placeholder Pattern
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun PlaceholderScreen(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
// Specific implementations
|
||||
@Composable
|
||||
fun SearchPlaceholder() = PlaceholderScreen(
|
||||
title = "Search",
|
||||
description = "Search for users, notes, and hashtags."
|
||||
)
|
||||
```
|
||||
|
||||
**Pattern**: Generic composable + specific wrappers with preset text
|
||||
|
||||
## Performance
|
||||
|
||||
### Avoid Unnecessary Recomposition
|
||||
|
||||
```kotlin
|
||||
// ❌ Bad - recomposes on every scroll
|
||||
@Composable
|
||||
fun BadButton(scrollState: ScrollState) {
|
||||
if (scrollState.value > 100) {
|
||||
Button(onClick = {}) { Text("Top") }
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Good - only recomposes when visibility changes
|
||||
@Composable
|
||||
fun GoodButton(scrollState: ScrollState) {
|
||||
val show by remember { derivedStateOf { scrollState.value > 100 } }
|
||||
if (show) {
|
||||
Button(onClick = {}) { Text("Top") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Lazy Lists
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedList(items: List<Item>) {
|
||||
LazyColumn {
|
||||
items(items, key = { it.id }) { item ->
|
||||
FeedItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key principle**: Use `key` parameter for stable item identity
|
||||
|
||||
## Bundled Resources
|
||||
|
||||
- **references/shared-composables-catalog.md** - Complete catalog of shared UI components
|
||||
- **references/state-patterns.md** - State management patterns with visual examples
|
||||
- **references/icon-assets.md** - Custom ImageVector icon patterns
|
||||
- **scripts/find-composables.sh** - Find all @Composable functions in codebase
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Pattern | Location |
|
||||
|------|---------|----------|
|
||||
| Reusable UI | State hoisting | commons/commonMain |
|
||||
| Simple state | remember { mutableStateOf() } | Composable scope |
|
||||
| Derived state | derivedStateOf { } | remember block |
|
||||
| Async → state | produceState { } | Composable function |
|
||||
| Custom icons | roboBuilder + PathData | commons/icons |
|
||||
| Loading/Error | LoadingState, ErrorState | commons/ui/components |
|
||||
| Theme colors | MaterialTheme.colorScheme | Any @Composable |
|
||||
| Navigation | Delegate to platform expert | amethyst/, desktopApp/ |
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Creating a Shared Component
|
||||
|
||||
1. Start in `commons/src/commonMain/kotlin/.../ui/components/`
|
||||
2. Use Material3 primitives only
|
||||
3. Hoist state (parameters for data, callbacks for events)
|
||||
4. Add modifier parameter
|
||||
5. Use MaterialTheme for colors/typography
|
||||
6. Test on both Android and Desktop
|
||||
|
||||
### Converting Existing Component
|
||||
|
||||
1. Read current implementation in `amethyst/` or `desktopApp/`
|
||||
2. Identify pure visual logic (no platform APIs)
|
||||
3. Create in `commons/commonMain` with hoisted state
|
||||
4. Replace platform implementations with shared component
|
||||
5. Keep platform-specific wrappers if needed
|
||||
|
||||
### Custom Icon
|
||||
|
||||
1. Export SVG from design tool
|
||||
2. Convert to PathData using Android Studio
|
||||
3. Create icon function with roboBuilder
|
||||
4. Add caching if generated dynamically
|
||||
5. Wrap in @Composable for easy use
|
||||
|
||||
### Navigation (Delegate)
|
||||
|
||||
For navigation patterns:
|
||||
- Android bottom nav → `android-expert`
|
||||
- Desktop sidebar → `desktop-expert`
|
||||
- Multi-window → `desktop-expert`
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **kotlin-expert** - Kotlin language aspects (@Immutable details, StateFlow, sealed classes)
|
||||
- **android-expert** - Android navigation, platform APIs
|
||||
- **desktop-expert** - Desktop navigation, window management, OS specifics
|
||||
- **kotlin-coroutines** - Async patterns, Flow integration
|
||||
@@ -0,0 +1,365 @@
|
||||
# Custom Icon Assets and ImageVector Patterns
|
||||
|
||||
Guide to creating and using custom ImageVector icons in Compose Multiplatform.
|
||||
|
||||
## Why ImageVector?
|
||||
|
||||
ImageVector is the native Compose format for vector graphics:
|
||||
- **Pure Kotlin**: No XML, no asset files
|
||||
- **Multiplatform**: Works on Android, Desktop, iOS without conversion
|
||||
- **Performant**: Lightweight, composable, GPU-accelerated
|
||||
- **Type-safe**: Compile-time checking, no resource IDs
|
||||
|
||||
## Amethyst Pattern: Robohash
|
||||
|
||||
Amethyst generates deterministic avatars using ImageVector builders.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
commons/robohash/
|
||||
├── RobohashAssembler.kt # Main assembly logic
|
||||
├── CachedRobohash.kt # Caching layer
|
||||
└── parts/
|
||||
├── Face0C3po.kt # Face variants (0-9)
|
||||
├── Eyes2Single.kt # Eye variants (0-9)
|
||||
├── Mouth3Grid.kt # Mouth variants (0-9)
|
||||
├── Body2Thinnest.kt # Body variants (0-9)
|
||||
└── Accessory7Antenna.kt # Accessory variants (0-9)
|
||||
```
|
||||
|
||||
**Pattern**: 10 variants per feature × 5 features = 100,000+ unique combinations
|
||||
|
||||
### roboBuilder DSL
|
||||
|
||||
Custom ImageVector builder with sensible defaults:
|
||||
|
||||
```kotlin
|
||||
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = "Robohash",
|
||||
defaultWidth = 300.dp,
|
||||
defaultHeight = 300.dp,
|
||||
viewportWidth = 300f,
|
||||
viewportHeight = 300f
|
||||
).apply(block).build()
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun CustomIcon() {
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
// Add paths here
|
||||
}
|
||||
),
|
||||
contentDescription = "Custom icon"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Path Building Pattern
|
||||
|
||||
```kotlin
|
||||
fun face0C3po(fgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
|
||||
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
|
||||
builder.addPath(pathData5, fill = Black, fillAlpha = 0.2f)
|
||||
builder.addPath(pathData6, stroke = Black, strokeLineWidth = 1.0f)
|
||||
builder.addPath(pathData7, fill = Black, stroke = Black, fillAlpha = 0.2f, strokeLineWidth = 0.75f)
|
||||
}
|
||||
|
||||
private val pathData1 = PathData {
|
||||
moveTo(144.5f, 87.5f)
|
||||
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
|
||||
curveToRelative(0.0f, 0.0f, 0.0f, 27.0f, 5.0f, 42.0f)
|
||||
reflectiveCurveToRelative(10.0f, 38.0f, 10.0f, 38.0f)
|
||||
lineToRelative(16.0f, 16.0f)
|
||||
// ...
|
||||
close()
|
||||
}
|
||||
```
|
||||
|
||||
**Key elements**:
|
||||
- `pathData` variables for path commands
|
||||
- `addPath()` for each layer
|
||||
- Parameterized colors (`fgColor`)
|
||||
- Constant colors (`Black`)
|
||||
- Alpha for shadows/highlights
|
||||
|
||||
### PathData DSL
|
||||
|
||||
Compose's PathData builder provides SVG-like commands:
|
||||
|
||||
| Command | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| `moveTo(x, y)` | Move pen without drawing | `moveTo(100f, 100f)` |
|
||||
| `lineTo(x, y)` | Draw line to point | `lineTo(200f, 150f)` |
|
||||
| `curveToRelative(...)` | Relative cubic Bézier | `curveToRelative(10f, 20f, 30f, 40f, 50f, 60f)` |
|
||||
| `reflectiveCurveToRelative(...)` | Smooth curve | `reflectiveCurveToRelative(-51f, 3f, -53f, 55f)` |
|
||||
| `horizontalLineTo(x)` | Horizontal line | `horizontalLineTo(250f)` |
|
||||
| `verticalLineTo(y)` | Vertical line | `verticalLineTo(300f)` |
|
||||
| `close()` | Close path | `close()` |
|
||||
|
||||
**Relative vs Absolute**:
|
||||
- `moveTo` / `lineTo` - Absolute coordinates
|
||||
- `moveToRelative` / `lineToRelative` - Relative to current position
|
||||
|
||||
## Creating Custom Icons
|
||||
|
||||
### Method 1: From SVG (Recommended)
|
||||
|
||||
1. **Export SVG** from design tool (Figma, Illustrator)
|
||||
2. **Convert to ImageVector** using Android Studio's Vector Asset tool
|
||||
3. **Extract path data** and adapt to roboBuilder pattern
|
||||
|
||||
```kotlin
|
||||
// SVG path: M 10 10 L 20 20 ...
|
||||
// Becomes:
|
||||
private val myIconPath = PathData {
|
||||
moveTo(10f, 10f)
|
||||
lineTo(20f, 20f)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Programmatic
|
||||
|
||||
Build paths programmatically for simple shapes:
|
||||
|
||||
```kotlin
|
||||
fun simpleIcon(): ImageVector = roboBuilder {
|
||||
addPath(
|
||||
pathData = PathData {
|
||||
moveTo(50f, 50f)
|
||||
lineTo(150f, 50f)
|
||||
lineTo(150f, 150f)
|
||||
lineTo(50f, 150f)
|
||||
close()
|
||||
},
|
||||
fill = SolidColor(Color.Blue),
|
||||
stroke = SolidColor(Color.Black),
|
||||
strokeLineWidth = 2f
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Method 3: Material Icons Extensions
|
||||
|
||||
Extend Material Icons when you need platform-consistent icons:
|
||||
|
||||
```kotlin
|
||||
// For standard icons, use Material Icons
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
Icon(Icons.Default.Check, contentDescription = "Success")
|
||||
Icon(Icons.Default.Close, contentDescription = "Error")
|
||||
```
|
||||
|
||||
## CachedRobohash Pattern
|
||||
|
||||
Performance optimization for generated icons:
|
||||
|
||||
```kotlin
|
||||
object CachedRobohash {
|
||||
private val cache = mutableMapOf<Pair<String, Boolean>, ImageVector>()
|
||||
|
||||
fun get(seed: String, isLight: Boolean): ImageVector {
|
||||
return cache.getOrPut(seed to isLight) {
|
||||
RobohashAssembler.assemble(seed, isLight)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**:
|
||||
- Key: `(seed, theme)` pair
|
||||
- Value: Assembled ImageVector
|
||||
- Lifecycle: Application lifetime (never cleared)
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RobohashImage(robot: String) {
|
||||
Image(
|
||||
imageVector = CachedRobohash.get(robot, isLightTheme()),
|
||||
contentDescription = "Avatar for $robot"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Color Management
|
||||
|
||||
### Dynamic Colors
|
||||
|
||||
Pass colors as parameters for theme adaptation:
|
||||
|
||||
```kotlin
|
||||
fun themedIcon(fgColor: SolidColor, bgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = bgColor)
|
||||
builder.addPath(pathData2, fill = fgColor)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemedIcon() {
|
||||
val fg = MaterialTheme.colorScheme.primary
|
||||
val bg = MaterialTheme.colorScheme.surface
|
||||
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
themedIcon(SolidColor(fg), SolidColor(bg), this)
|
||||
}
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Static Colors
|
||||
|
||||
Define constants for colors that don't change:
|
||||
|
||||
```kotlin
|
||||
val Black = SolidColor(Color.Black)
|
||||
val White = SolidColor(Color.White)
|
||||
val Transparent = SolidColor(Color.Transparent)
|
||||
```
|
||||
|
||||
## Advanced Techniques
|
||||
|
||||
### Layering
|
||||
|
||||
Build complex icons with multiple layers:
|
||||
|
||||
```kotlin
|
||||
fun complexIcon(builder: Builder) {
|
||||
// Layer 1: Background
|
||||
builder.addPath(bgPath, fill = SolidColor(Color.White))
|
||||
|
||||
// Layer 2: Shadow
|
||||
builder.addPath(shadowPath, fill = SolidColor(Color.Black), fillAlpha = 0.2f)
|
||||
|
||||
// Layer 3: Main shape
|
||||
builder.addPath(mainPath, fill = SolidColor(Color.Blue))
|
||||
|
||||
// Layer 4: Highlight
|
||||
builder.addPath(highlightPath, fill = SolidColor(Color.White), fillAlpha = 0.3f)
|
||||
|
||||
// Layer 5: Stroke
|
||||
builder.addPath(outlinePath, stroke = SolidColor(Color.Black), strokeLineWidth = 1f)
|
||||
}
|
||||
```
|
||||
|
||||
**Render order**: Bottom to top (first addPath = bottom layer)
|
||||
|
||||
### Alpha for Visual Effects
|
||||
|
||||
```kotlin
|
||||
// Shadow
|
||||
builder.addPath(shadowPath, fill = Black, fillAlpha = 0.4f)
|
||||
|
||||
// Highlight
|
||||
builder.addPath(highlightPath, fill = White, fillAlpha = 0.2f)
|
||||
|
||||
// Glass effect
|
||||
builder.addPath(glassPath, fill = White, fillAlpha = 0.1f)
|
||||
```
|
||||
|
||||
### Stroke Styles
|
||||
|
||||
```kotlin
|
||||
// Outline only
|
||||
builder.addPath(path, stroke = Black, strokeLineWidth = 1.5f)
|
||||
|
||||
// Fill + outline
|
||||
builder.addPath(path, fill = fgColor, stroke = Black, strokeLineWidth = 1f)
|
||||
|
||||
// Dashed (not supported directly, use multiple segments)
|
||||
```
|
||||
|
||||
## Composable Icon Pattern
|
||||
|
||||
Wrap ImageVector in a Composable for reusability:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MyCustomIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = Color.Unspecified
|
||||
) {
|
||||
Image(
|
||||
painter = rememberVectorPainter(myIconVector()),
|
||||
contentDescription = "My custom icon",
|
||||
modifier = modifier,
|
||||
colorFilter = if (tint != Color.Unspecified) {
|
||||
ColorFilter.tint(tint)
|
||||
} else null
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
MyCustomIcon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO
|
||||
✅ Cache generated ImageVectors for performance
|
||||
✅ Use PathData DSL for readability
|
||||
✅ Parameterize colors for theme support
|
||||
✅ Use Material Icons for standard icons
|
||||
✅ Keep viewport size consistent (e.g., 300×300)
|
||||
✅ Layer paths from back to front
|
||||
✅ Use alpha for shadows and highlights
|
||||
|
||||
### DON'T
|
||||
❌ Generate ImageVectors in @Composable without caching
|
||||
❌ Hardcode theme-specific colors
|
||||
❌ Create custom icons for standard Material icons
|
||||
❌ Use extreme viewport sizes (stay 24-1000dp)
|
||||
❌ Mix absolute and relative coordinates unnecessarily
|
||||
❌ Forget to close() paths
|
||||
|
||||
## Icon Organization
|
||||
|
||||
### Structure
|
||||
```
|
||||
commons/icons/
|
||||
├── CustomIcons.kt # Icon collection object
|
||||
├── icons/
|
||||
│ ├── Zap.kt # Lightning bolt
|
||||
│ ├── Relay.kt # Relay indicator
|
||||
│ └── Bitcoin.kt # Bitcoin symbol
|
||||
└── builders/
|
||||
└── IconBuilder.kt # Shared builder utilities
|
||||
```
|
||||
|
||||
### Collection Object
|
||||
```kotlin
|
||||
object CustomIcons {
|
||||
val Zap: ImageVector by lazy { ZapIcon.create() }
|
||||
val Relay: ImageVector by lazy { RelayIcon.create() }
|
||||
val Bitcoin: ImageVector by lazy { BitcoinIcon.create() }
|
||||
}
|
||||
|
||||
// Usage
|
||||
Icon(CustomIcons.Zap, contentDescription = "Zap")
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Compose ImageVector API](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/vector/ImageVector)
|
||||
- [SVG Path Commands](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths)
|
||||
- [Material Icons](https://fonts.google.com/icons)
|
||||
- Robohash implementation: `commons/robohash/` in AmethystMultiplatform
|
||||
@@ -0,0 +1,281 @@
|
||||
# Shared Composables Catalog
|
||||
|
||||
This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
commons/src/commonMain/kotlin/.../commons/ui/
|
||||
├── components/ # Reusable UI components
|
||||
├── screens/ # Screen-level composables
|
||||
├── theme/ # Theming and styling
|
||||
└── feed/ # Feed-specific components
|
||||
```
|
||||
|
||||
## Components (`ui/components/`)
|
||||
|
||||
### State Visualization
|
||||
|
||||
**LoadingState** - Centered loading indicator with message
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoadingState(message: String, modifier: Modifier = Modifier)
|
||||
```
|
||||
- Use for: Async operations, data fetching
|
||||
- Pattern: fillMaxSize, centered Column, CircularProgressIndicator
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**EmptyState** - Centered empty state with optional refresh
|
||||
```kotlin
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
description: String? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
refreshLabel: String = "Refresh"
|
||||
)
|
||||
```
|
||||
- Use for: Empty lists, no data scenarios
|
||||
- Pattern: Centered Column, optional OutlinedButton
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**ErrorState** - Centered error message with retry
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ErrorState(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
retryLabel: String = "Try Again"
|
||||
)
|
||||
```
|
||||
- Use for: Error handling, failed operations
|
||||
- Pattern: error color, optional Button
|
||||
- Works on: Android, Desktop
|
||||
|
||||
### Feed-Specific States
|
||||
|
||||
**FeedEmptyState** - Pre-configured empty state for feeds
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedEmptyState(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Feed is empty",
|
||||
onRefresh: (() -> Unit)? = null
|
||||
)
|
||||
```
|
||||
|
||||
**FeedErrorState** - Pre-configured error state for feeds
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedErrorState(
|
||||
errorMessage: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null
|
||||
)
|
||||
```
|
||||
|
||||
### Action Buttons
|
||||
|
||||
**Shared Constants**:
|
||||
```kotlin
|
||||
val ActionButtonShape = RoundedCornerShape(20.dp)
|
||||
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
|
||||
```
|
||||
|
||||
**AddButton** - Consistent "Add" action button
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Add",
|
||||
enabled: Boolean = true
|
||||
)
|
||||
```
|
||||
- Pattern: OutlinedButton with consistent shape/padding
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**RemoveButton** - Consistent "Remove" action button
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RemoveButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Remove",
|
||||
enabled: Boolean = true
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Images
|
||||
|
||||
**RobohashImage** - Deterministic avatar generation
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RobohashImage(
|
||||
robot: String, // Seed (e.g., pubkey)
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
loadRobohash: Boolean = true
|
||||
)
|
||||
|
||||
// Overload with more options
|
||||
@Composable
|
||||
fun RobohashImage(
|
||||
robot: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
colorFilter: ColorFilter? = null,
|
||||
loadRobohash: Boolean = true
|
||||
)
|
||||
```
|
||||
- Use for: User avatars, deterministic graphics
|
||||
- Pattern: Uses CachedRobohash.get(), isLightTheme() detection
|
||||
- Fallback: Icons.Default.Face
|
||||
- Works on: Android, Desktop (pure ImageVector)
|
||||
|
||||
**Theme Detection Helper**:
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun isLightTheme(): Boolean {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
||||
}
|
||||
```
|
||||
|
||||
## Feed Components (`ui/feed/`)
|
||||
|
||||
### FeedHeader
|
||||
|
||||
**FeedHeader** - Screen header with title and relay status
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedHeader(
|
||||
title: String,
|
||||
connectedRelayCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
```
|
||||
- Pattern: Row with SpaceBetween, title + RelayStatusIndicator
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**RelayStatusIndicator** - Compact relay connection indicator
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayStatusIndicator(
|
||||
connectedCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
```
|
||||
- Pattern: Status icon + count text + refresh button
|
||||
- Colors: RelayStatusColors.{Disconnected, Connecting, Connected}
|
||||
- Visual cues: Check icon (connected), Close icon (disconnected)
|
||||
|
||||
## Screens (`ui/screens/`)
|
||||
|
||||
### Placeholder Pattern
|
||||
|
||||
**PlaceholderScreen** - Generic placeholder
|
||||
```kotlin
|
||||
@Composable
|
||||
fun PlaceholderScreen(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
```
|
||||
- Pattern: Column with title (headlineMedium) + description
|
||||
- Use for: Unimplemented screens, coming soon features
|
||||
|
||||
**Specific Placeholders**:
|
||||
- `SearchPlaceholder()` - Search screen
|
||||
- `MessagesPlaceholder()` - DMs screen
|
||||
- `NotificationsPlaceholder()` - Notifications screen
|
||||
|
||||
Pattern: Specific implementations wrap PlaceholderScreen with preset text.
|
||||
|
||||
## Custom Icons (`robohash/parts/`)
|
||||
|
||||
### ImageVector Builder Pattern
|
||||
|
||||
Amethyst uses a custom DSL for building ImageVector assets:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Face0C3po() {
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
face0C3po(SolidColor(Color.Blue), this)
|
||||
}
|
||||
),
|
||||
contentDescription = ""
|
||||
)
|
||||
}
|
||||
|
||||
fun face0C3po(fgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
|
||||
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
|
||||
// ...
|
||||
}
|
||||
|
||||
private val pathData1 = PathData {
|
||||
moveTo(144.5f, 87.5f)
|
||||
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
|
||||
// ... path commands
|
||||
}
|
||||
```
|
||||
|
||||
**roboBuilder** - Custom ImageVector.Builder DSL
|
||||
- Located in: `commons/robohash/`
|
||||
- Pattern: Builder-based, composable paths
|
||||
- Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each)
|
||||
- Colors: Dynamic (fgColor parameter) + Black constants
|
||||
|
||||
### CachedRobohash
|
||||
|
||||
```kotlin
|
||||
CachedRobohash.get(seed: String, isLight: Boolean): ImageVector
|
||||
```
|
||||
- Deterministic: Same seed → same avatar
|
||||
- Theme-aware: Different colors for light/dark
|
||||
- Cached: Performance optimization
|
||||
- Pure ImageVector: Works on all platforms
|
||||
|
||||
## Sharing Guidelines
|
||||
|
||||
### Always Share
|
||||
- State visualization (Loading, Empty, Error)
|
||||
- Action buttons with consistent styling
|
||||
- Generic placeholders
|
||||
- Custom ImageVector icons
|
||||
- Material3 themed components
|
||||
- Theme utilities (isLightTheme)
|
||||
|
||||
### Platform-Specific (Delegate to Experts)
|
||||
- Navigation structure (android-expert, desktop-expert)
|
||||
- Screen layouts and scaffolds
|
||||
- Platform system integrations
|
||||
- Gesture handling specifics
|
||||
|
||||
### Decision Framework
|
||||
1. **Can it use Material3 primitives?** → Share
|
||||
2. **Does it need platform system APIs?** → Platform-specific
|
||||
3. **Is it a visual component without navigation?** → Share
|
||||
4. **Does it require platform UX patterns?** → Ask platform expert
|
||||
|
||||
## Material3 Usage
|
||||
|
||||
All shared composables use Material3:
|
||||
- `MaterialTheme.colorScheme.*` for colors
|
||||
- `MaterialTheme.typography.*` for text styles
|
||||
- `OutlinedButton`, `Button`, `IconButton` for actions
|
||||
- `CircularProgressIndicator` for loading
|
||||
- `Icon`, `Image` for visuals
|
||||
|
||||
This ensures consistent theming across Android and Desktop.
|
||||
@@ -0,0 +1,334 @@
|
||||
# Compose State Management Patterns
|
||||
|
||||
Visual guide to state management in Compose Multiplatform. For Kotlin-specific patterns (StateFlow, sealed classes), see `kotlin-expert` skill.
|
||||
|
||||
## Core State Functions
|
||||
|
||||
### remember
|
||||
|
||||
Cache values across recompositions:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Counter() {
|
||||
var count by remember { mutableStateOf(0) }
|
||||
|
||||
Button(onClick = { count++ }) {
|
||||
Text("Clicked $count times")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Simple UI state (toggles, counters, text input)
|
||||
**Visual pattern**: Button press → state changes → UI updates
|
||||
|
||||
### derivedStateOf
|
||||
|
||||
Compute state from other state, recompose only when result changes:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ScrollToTopButton(listState: LazyListState) {
|
||||
// Only recomposes when showButton value changes (not every scroll pixel)
|
||||
val showButton by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0
|
||||
}
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
FloatingActionButton(onClick = { /* scroll to top */ }) {
|
||||
Icon(Icons.Default.ArrowUpward, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Input state changes frequently, but derived result changes rarely
|
||||
**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → FAB visibility
|
||||
**Performance**: Prevents recomposition on every scroll event
|
||||
|
||||
### produceState
|
||||
|
||||
Convert non-Compose state into Compose state:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoadUserProfile(userId: String): State<User?> {
|
||||
return produceState<User?>(initialValue = null, userId) {
|
||||
value = repository.fetchUser(userId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(userId: String) {
|
||||
val user by LoadUserProfile(userId)
|
||||
|
||||
when (user) {
|
||||
null -> LoadingState("Loading profile...")
|
||||
else -> ProfileCard(user!!)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Convert Flow, LiveData, callbacks into Compose state
|
||||
**Visual pattern**: Async operation → state updates → UI reflects changes
|
||||
**Lifecycle**: Coroutine cancelled when composable leaves composition
|
||||
|
||||
## State Hoisting Pattern
|
||||
|
||||
Move state up to make composables reusable and testable:
|
||||
|
||||
### Before (Stateful)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchBar() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search...") }
|
||||
)
|
||||
}
|
||||
```
|
||||
❌ Hard to test, can't control state externally
|
||||
|
||||
### After (Stateless)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Search...") },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchScreen() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
Column {
|
||||
SearchBar(query = query, onQueryChange = { query = it })
|
||||
SearchResults(query = query)
|
||||
}
|
||||
}
|
||||
```
|
||||
✅ Reusable, testable, state controlled by parent
|
||||
|
||||
**Hoisting principle**: State goes up, events go down
|
||||
- State: `query: String` (read-only)
|
||||
- Events: `onQueryChange: (String) -> Unit` (write-only)
|
||||
|
||||
## Amethyst State Patterns
|
||||
|
||||
### Theme-Aware State
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun isLightTheme(): Boolean {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemedContent() {
|
||||
val isDark = !isLightTheme()
|
||||
// Adjust visuals based on theme
|
||||
val iconTint = if (isDark) Color.White else Color.Black
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Derive state from MaterialTheme
|
||||
**Visual**: Component adapts to light/dark theme automatically
|
||||
|
||||
### Relay Status State
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayStatusIndicator(
|
||||
connectedCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val statusColor = when {
|
||||
connectedCount == 0 -> RelayStatusColors.Disconnected
|
||||
connectedCount < 3 -> RelayStatusColors.Connecting
|
||||
else -> RelayStatusColors.Connected
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
|
||||
tint = statusColor
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Visual state derived from domain state
|
||||
**Visual mapping**:
|
||||
- 0 relays → Red + X icon
|
||||
- 1-2 relays → Yellow + Check icon
|
||||
- 3+ relays → Green + Check icon
|
||||
|
||||
### Loading/Empty/Error States
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedScreen(viewModel: FeedViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
when (uiState) {
|
||||
is UiState.Loading -> LoadingState("Loading feed...")
|
||||
is UiState.Empty -> FeedEmptyState(onRefresh = { viewModel.refresh() })
|
||||
is UiState.Error -> FeedErrorState(
|
||||
errorMessage = uiState.message,
|
||||
onRetry = { viewModel.retry() }
|
||||
)
|
||||
is UiState.Success -> LazyColumn {
|
||||
items(uiState.items) { FeedItem(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Sealed class → visual state component
|
||||
**Components**:
|
||||
- `LoadingState` - Progress indicator
|
||||
- `EmptyState` - Empty message + refresh
|
||||
- `ErrorState` - Error message + retry
|
||||
- Success - Actual content
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Toggle State
|
||||
```kotlin
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(onClick = { isExpanded = !isExpanded }) {
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand"
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
Text("Expanded content...")
|
||||
}
|
||||
```
|
||||
|
||||
### List State with Actions
|
||||
```kotlin
|
||||
var items by remember { mutableStateOf(listOf("Item 1", "Item 2")) }
|
||||
|
||||
Column {
|
||||
AddButton(onClick = {
|
||||
items = items + "Item ${items.size + 1}"
|
||||
})
|
||||
|
||||
items.forEachIndexed { index, item ->
|
||||
Row {
|
||||
Text(item)
|
||||
RemoveButton(onClick = {
|
||||
items = items.filterIndexed { i, _ -> i != index }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TextField State
|
||||
```kotlin
|
||||
var text by remember { mutableStateOf("") }
|
||||
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Enter text") }
|
||||
)
|
||||
```
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### Avoid Unnecessary Recomposition
|
||||
```kotlin
|
||||
// ❌ Bad: Recomposes on every scroll position change
|
||||
@Composable
|
||||
fun BadScrollButton(scrollState: ScrollState) {
|
||||
if (scrollState.value > 100) { // scrollState.value changes constantly
|
||||
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Good: Only recomposes when visibility changes
|
||||
@Composable
|
||||
fun GoodScrollButton(scrollState: ScrollState) {
|
||||
val showButton by remember {
|
||||
derivedStateOf { scrollState.value > 100 }
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Stable Parameters
|
||||
Use `@Immutable` data classes (see `kotlin-expert`) to prevent recomposition:
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class UserProfile(val name: String, val avatar: String)
|
||||
|
||||
@Composable
|
||||
fun ProfileCard(profile: UserProfile) {
|
||||
// Only recomposes when profile instance changes
|
||||
Row {
|
||||
RobohashImage(robot = profile.avatar)
|
||||
Text(profile.name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Kotlin State
|
||||
|
||||
For ViewModel state, Flow, StateFlow → See `kotlin-expert` skill
|
||||
|
||||
Common integration pattern:
|
||||
```kotlin
|
||||
// ViewModel (Kotlin state)
|
||||
class FeedViewModel {
|
||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
}
|
||||
|
||||
// Composable (Compose state)
|
||||
@Composable
|
||||
fun FeedScreen(viewModel: FeedViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
// Use uiState to render UI
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Function | Use Case | Recomposes When |
|
||||
|----------|----------|----------------|
|
||||
| `remember { mutableStateOf() }` | Local UI state | State value changes |
|
||||
| `derivedStateOf { }` | Computed state | Derived result changes |
|
||||
| `produceState { }` | Async/Flow → State | Async operation updates value |
|
||||
| `collectAsState()` | Flow → State | Flow emits new value |
|
||||
| State hoisting | Reusable components | Parent passes new state |
|
||||
|
||||
## Sources
|
||||
|
||||
State management patterns based on:
|
||||
- [State and Jetpack Compose - Android Developers](https://developer.android.com/develop/ui/compose/state)
|
||||
- [When should I use derivedStateOf?](https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b)
|
||||
- [Advanced State and Side Effects](https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects)
|
||||
- AmethystMultiplatform codebase patterns (2025)
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Find all @Composable functions in the codebase
|
||||
|
||||
set -e
|
||||
|
||||
# Default to current directory if no path provided
|
||||
SEARCH_PATH="${1:-.}"
|
||||
|
||||
echo "Searching for @Composable functions in: $SEARCH_PATH"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Find all @Composable functions with file paths and line numbers
|
||||
grep -r -n "@Composable" "$SEARCH_PATH" \
|
||||
--include="*.kt" \
|
||||
--exclude-dir=build \
|
||||
--exclude-dir=.gradle \
|
||||
| while IFS=: read -r file line content; do
|
||||
# Extract function name if possible
|
||||
if [[ $content =~ fun[[:space:]]+([a-zA-Z0-9_]+) ]]; then
|
||||
func_name="${BASH_REMATCH[1]}"
|
||||
echo "$file:$line - $func_name"
|
||||
else
|
||||
echo "$file:$line"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Total @Composable functions found:"
|
||||
grep -r "@Composable" "$SEARCH_PATH" \
|
||||
--include="*.kt" \
|
||||
--exclude-dir=build \
|
||||
--exclude-dir=.gradle \
|
||||
| wc -l
|
||||
@@ -0,0 +1,548 @@
|
||||
---
|
||||
name: gradle-expert
|
||||
description: Build optimization, dependency resolution, and multi-module KMP troubleshooting for AmethystMultiplatform. Use when working with: (1) Gradle build files (build.gradle.kts, settings.gradle), (2) Version catalog (libs.versions.toml), (3) Build errors and dependency conflicts, (4) Module dependencies and source sets, (5) Desktop packaging (DMG/MSI/DEB), (6) Build performance optimization, (7) Proguard/R8 configuration, (8) Common KMP + Android Gradle issues (Compose conflicts, secp256k1 JNI variants, source set problems).
|
||||
---
|
||||
|
||||
# Gradle Expert
|
||||
|
||||
Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
|
||||
|
||||
## Build Architecture Mental Model
|
||||
|
||||
Think of this project as **4 layers**:
|
||||
|
||||
```
|
||||
┌─────────────┬─────────────┐
|
||||
│ :amethyst │ :desktopApp │ ← Platform apps (navigation, layouts)
|
||||
│ (Android) │ (JVM) │
|
||||
└──────┬──────┴──────┬──────┘
|
||||
│ │
|
||||
└──────┬──────┘
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :commons │ ← Shared UI (KMP with jvmAndroid)
|
||||
│ (KMP UI) │
|
||||
└──────┬──────┘
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :quartz │ ← Core library (KMP: Android/JVM/iOS)
|
||||
│(KMP Library)│
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
**Key insight:** Dependencies flow DOWN. Lower modules never depend on upper modules. This enables code sharing without circular dependencies.
|
||||
|
||||
**The jvmAndroid pattern:** Unique to this project. A custom source set between commonMain and {androidMain, jvmMain} for JVM-specific code shared by Android and Desktop. Not standard KMP, but critical for this architecture.
|
||||
|
||||
## Version Catalog Philosophy
|
||||
|
||||
All dependencies centralized in `gradle/libs.versions.toml`. Think "single source of truth."
|
||||
|
||||
**Pattern:**
|
||||
```toml
|
||||
[versions]
|
||||
kotlin = "2.3.0"
|
||||
|
||||
[libraries]
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
|
||||
[plugins]
|
||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation(libs.okhttp) // Type-safe, IDE-autocompleted
|
||||
}
|
||||
```
|
||||
|
||||
**Critical alignments:**
|
||||
- **Kotlin ecosystem:** All Kotlin plugins MUST share same version
|
||||
- **Compose ecosystem:** Compose Multiplatform version → Kotlin version (check compatibility matrix)
|
||||
- **secp256k1 variants:** All three variants (common, jni-android, jni-jvm) MUST share same version
|
||||
|
||||
See [references/version-catalog-guide.md](references/version-catalog-guide.md) for comprehensive patterns.
|
||||
|
||||
## Common Build Tasks
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```bash
|
||||
# Full builds
|
||||
./gradlew build # All modules
|
||||
./gradlew clean build # Clean build
|
||||
|
||||
# Desktop
|
||||
./gradlew :desktopApp:run # Run desktop app
|
||||
./gradlew :desktopApp:packageDmg # macOS package
|
||||
|
||||
# Module-specific
|
||||
./gradlew :quartz:build # KMP library only
|
||||
./gradlew :commons:build # Shared UI only
|
||||
|
||||
# Analysis
|
||||
./gradlew dependencies # Dependency tree
|
||||
./gradlew build --scan # Online diagnostics
|
||||
```
|
||||
|
||||
See [references/build-commands.md](references/build-commands.md) for comprehensive command reference.
|
||||
|
||||
## Module Structure & Dependencies
|
||||
|
||||
### Dependency Flow
|
||||
|
||||
**Desktop build chain:**
|
||||
```
|
||||
:desktopApp → :commons (jvmMain) → :quartz (jvmMain → jvmAndroid → commonMain)
|
||||
```
|
||||
|
||||
**Android build chain:**
|
||||
```
|
||||
:amethyst → :commons (androidMain) → :quartz (androidMain → jvmAndroid → commonMain)
|
||||
```
|
||||
|
||||
**Key source set pattern (quartz & commons):**
|
||||
```
|
||||
commonMain # Truly cross-platform code
|
||||
│
|
||||
├─ jvmAndroid # JVM-specific, shared by Android + Desktop
|
||||
│ ├─ androidMain
|
||||
│ └─ jvmMain
|
||||
│
|
||||
└─ iosMain # iOS-specific (quartz only)
|
||||
```
|
||||
|
||||
**Dependency config types:**
|
||||
- Use `api` when types appear in module's public API or expect/actual declarations
|
||||
- Use `implementation` for internal implementation details
|
||||
- Example: quartz exposes secp256k1 (`api`), but hides okhttp (`implementation`)
|
||||
|
||||
See [references/dependency-graph.md](references/dependency-graph.md) for module visualization and transitive dependency flow.
|
||||
|
||||
## Critical Dependency Patterns
|
||||
|
||||
### 1. secp256k1 (Crypto Library)
|
||||
|
||||
**The problem:** KMP library with platform-specific JNI bindings. Wrong variant = runtime crash.
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
// commonMain - API only
|
||||
api(libs.secp256k1.kmp.common)
|
||||
|
||||
// androidMain - Android JNI
|
||||
api(libs.secp256k1.kmp.jni.android)
|
||||
|
||||
// jvmMain - Desktop JVM JNI
|
||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
||||
```
|
||||
|
||||
**Why api in androidMain?** Types leak to consumers (:amethyst).
|
||||
|
||||
**Common error:** Desktop using jni-android variant → `UnsatisfiedLinkError: no secp256k1jni in java.library.path`
|
||||
|
||||
**Fix:** Check source set dependencies. jvmMain must use jni-jvm, never jni-android.
|
||||
|
||||
### 2. JNA (for LibSodium Encryption)
|
||||
|
||||
**The problem:** Android needs AAR packaging, JVM needs JAR. Same library, different artifact types.
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
// androidMain
|
||||
implementation("com.goterl:lazysodium-android:5.2.0@aar") // @aar explicit
|
||||
implementation("net.java.dev.jna:jna:5.18.1@aar")
|
||||
|
||||
// jvmMain
|
||||
implementation(libs.lazysodium.java) // JAR implicit
|
||||
implementation(libs.jna)
|
||||
```
|
||||
|
||||
**Critical:** Never put JNA in jvmAndroid or commonMain. Platform-specific packaging only.
|
||||
|
||||
### 3. Compose Versions
|
||||
|
||||
**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes.
|
||||
|
||||
**Current project config:**
|
||||
```toml
|
||||
composeMultiplatform = "1.9.3" # Plugin + runtime
|
||||
composeBom = "2025.12.01" # AndroidX Compose BOM
|
||||
kotlin = "2.3.0"
|
||||
```
|
||||
|
||||
**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
|
||||
|
||||
**In KMP modules (quartz, commons):**
|
||||
```kotlin
|
||||
// ✅ Use Compose Multiplatform
|
||||
implementation(compose.ui)
|
||||
implementation(compose.material3)
|
||||
|
||||
// ❌ DON'T use AndroidX BOM in KMP modules
|
||||
// implementation(libs.androidx.compose.bom)
|
||||
```
|
||||
|
||||
**In Android-only modules (amethyst):**
|
||||
```kotlin
|
||||
// Can use AndroidX BOM
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
```
|
||||
|
||||
## Desktop Packaging Basics
|
||||
|
||||
**TargetFormat options:**
|
||||
```kotlin
|
||||
// In desktopApp/build.gradle.kts
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
|
||||
packageName = "Amethyst"
|
||||
packageVersion = "1.0.0"
|
||||
|
||||
macOS {
|
||||
bundleID = "com.vitorpamplona.amethyst.desktop"
|
||||
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Package tasks:**
|
||||
```bash
|
||||
./gradlew :desktopApp:packageDmg # macOS
|
||||
./gradlew :desktopApp:packageMsi # Windows
|
||||
./gradlew :desktopApp:packageDeb # Linux
|
||||
```
|
||||
|
||||
**Output locations:**
|
||||
- macOS: `desktopApp/build/compose/binaries/main/dmg/`
|
||||
- Windows: `desktopApp/build/compose/binaries/main/msi/`
|
||||
- Linux: `desktopApp/build/compose/binaries/main/deb/`
|
||||
|
||||
**Icon requirements:**
|
||||
- macOS: `.icns` (multi-resolution: 512, 256, 128, 32)
|
||||
- Windows: `.ico` (256, 128, 64, 32, 16)
|
||||
- Linux: `.png` (512x512)
|
||||
|
||||
**Common issues:**
|
||||
- Main class not found → Verify `mainClass = "...MainKt"` (Kotlin adds `Kt` suffix)
|
||||
- Native libs missing → Ensure secp256k1-kmp-jni-jvm in dependencies
|
||||
- Icon not found → Check file exists at path, use absolute path if needed
|
||||
|
||||
## Build Performance Optimization
|
||||
|
||||
**Add to `gradle.properties`:**
|
||||
```properties
|
||||
# Daemon (faster subsequent builds)
|
||||
org.gradle.daemon=true
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
|
||||
|
||||
# Parallel execution (multi-module speedup)
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=8
|
||||
|
||||
# Caching (incremental builds)
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
# Kotlin daemon
|
||||
kotlin.incremental=true
|
||||
kotlin.daemon.jvmargs=-Xmx2g
|
||||
```
|
||||
|
||||
**Impact:** Typically 30-50% faster builds after first run.
|
||||
|
||||
**Measure impact:**
|
||||
```bash
|
||||
./gradlew clean build --profile
|
||||
# Report: build/reports/profile/profile-<timestamp>.html
|
||||
```
|
||||
|
||||
**When to clean build:**
|
||||
- After changing version catalog
|
||||
- After adding/removing source sets
|
||||
- When seeing unexplained errors
|
||||
|
||||
**When NOT to clean:**
|
||||
- Regular development iteration
|
||||
- Small code changes
|
||||
- Incremental compilation works fine
|
||||
|
||||
Use script: `scripts/analyze-build-time.sh` for automated profiling.
|
||||
|
||||
## Troubleshooting: Practical Patterns
|
||||
|
||||
### Pattern 1: Version Conflict
|
||||
|
||||
**Symptom:** `Duplicate class` or `NoSuchMethodError`
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
./gradlew dependencyInsight --dependency <library-name>
|
||||
```
|
||||
|
||||
**Fix options:**
|
||||
1. Align versions in libs.versions.toml (preferred)
|
||||
2. Force resolution:
|
||||
```kotlin
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force(libs.okhttp.get().toString())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Source Set Issues
|
||||
|
||||
**Symptom:** `Unresolved reference` to JVM library in shared code
|
||||
|
||||
**Diagnosis:** Check source set hierarchy. JVM-only libs (jackson, okhttp) can't be in commonMain.
|
||||
|
||||
**Fix:** Move to jvmAndroid or platform-specific source set.
|
||||
|
||||
```kotlin
|
||||
// ❌ Wrong
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(libs.jackson.module.kotlin) // JVM-only!
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Correct
|
||||
val jvmAndroid = create("jvmAndroid") {
|
||||
dependsOn(commonMain.get())
|
||||
dependencies {
|
||||
api(libs.jackson.module.kotlin) // JVM code, shared by Android + Desktop
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Proguard Stripping Native Libs
|
||||
|
||||
**Symptom:** `NoClassDefFoundError` for secp256k1, JNA, or LibSodium in release builds
|
||||
|
||||
**Fix:** Update proguard rules in `quartz/proguard-rules.pro`:
|
||||
```proguard
|
||||
# Native libraries
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
-keep class com.goterl.lazysodium.** { *; }
|
||||
-keep class com.sun.jna.** { *; }
|
||||
|
||||
# Jackson (reflection-based)
|
||||
-keep class com.vitorpamplona.quartz.** { *; }
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Signature
|
||||
```
|
||||
|
||||
### Pattern 4: Compose Compiler Mismatch
|
||||
|
||||
**Symptom:** `IllegalStateException: Version mismatch: runtime 1.10.0 but compiler 1.9.0`
|
||||
|
||||
**Fix:** Update Compose Multiplatform version in libs.versions.toml to match Kotlin version compatibility.
|
||||
|
||||
Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
|
||||
|
||||
### Pattern 5: Wrong JVM Target
|
||||
|
||||
**Symptom:** `Unsupported class file major version 65`
|
||||
|
||||
**Fix:** Ensure Java 21 everywhere:
|
||||
```bash
|
||||
# Check current Java
|
||||
java -version # Should show 21
|
||||
|
||||
# Set JAVA_HOME
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
|
||||
|
||||
# Stop Gradle daemon to pick up new Java
|
||||
./gradlew --stop
|
||||
```
|
||||
|
||||
Verify all build files use JVM 21:
|
||||
```kotlin
|
||||
kotlin {
|
||||
jvm {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comprehensive Error Guide
|
||||
|
||||
For detailed troubleshooting of specific errors, see [references/common-errors.md](references/common-errors.md). Covers:
|
||||
- Compose version conflicts
|
||||
- secp256k1 JNI errors
|
||||
- Source set dependency issues
|
||||
- Proguard/R8 problems
|
||||
- Desktop packaging errors
|
||||
- Kotlin compilation errors
|
||||
- Dependency resolution failures
|
||||
- JVM/JDK version issues
|
||||
|
||||
Each error includes: symptom, cause, solution, verification steps.
|
||||
|
||||
## Quick Diagnostic Commands
|
||||
|
||||
```bash
|
||||
# Check dependencies for specific module
|
||||
./gradlew :quartz:dependencies
|
||||
|
||||
# Find specific library in dependency tree
|
||||
./gradlew dependencyInsight --dependency okhttp
|
||||
|
||||
# Build with detailed logging
|
||||
./gradlew build --info
|
||||
|
||||
# Generate interactive build scan (best diagnostics)
|
||||
./gradlew build --scan
|
||||
|
||||
# Profile build performance
|
||||
./gradlew clean build --profile
|
||||
|
||||
# Stop all Gradle daemons (fresh start)
|
||||
./gradlew --stop
|
||||
|
||||
# Check Gradle version
|
||||
./gradlew --version
|
||||
```
|
||||
|
||||
## Scripts & References
|
||||
|
||||
### Diagnostic Scripts
|
||||
- `scripts/analyze-build-time.sh` - Profile build performance, generate optimization report
|
||||
- `scripts/fix-dependency-conflicts.sh` - Diagnose common dependency conflicts, suggest fixes
|
||||
|
||||
### Reference Docs
|
||||
- `references/build-commands.md` - Comprehensive command reference for all tasks
|
||||
- `references/dependency-graph.md` - Module dependencies, source set hierarchy, transitive deps
|
||||
- `references/version-catalog-guide.md` - Version catalog patterns, usage, best practices
|
||||
- `references/common-errors.md` - Troubleshooting guide for frequent build issues
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
### Example 1: Adding New Dependency
|
||||
|
||||
**Task:** Add kotlinx.datetime to quartz
|
||||
|
||||
**Steps:**
|
||||
1. **Update version catalog** (gradle/libs.versions.toml):
|
||||
```toml
|
||||
[versions]
|
||||
kotlinxDatetime = "0.6.0"
|
||||
|
||||
[libraries]
|
||||
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
||||
```
|
||||
|
||||
2. **Add to build file** (quartz/build.gradle.kts):
|
||||
```kotlin
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.datetime) // KMP library, goes in commonMain
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Sync & verify:**
|
||||
```bash
|
||||
./gradlew :quartz:dependencies | grep datetime
|
||||
```
|
||||
|
||||
### Example 2: Fixing secp256k1 Error on Desktop
|
||||
|
||||
**Error:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path` when running desktop app
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1
|
||||
# Shows: secp256k1-kmp-jni-android ← WRONG!
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
```kotlin
|
||||
// In quartz/build.gradle.kts
|
||||
jvmMain {
|
||||
dependencies {
|
||||
// Change from:
|
||||
// implementation(libs.secp256k1.kmp.jni.android) ❌
|
||||
|
||||
// To:
|
||||
implementation(libs.secp256k1.kmp.jni.jvm) // ✅
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
./gradlew :desktopApp:dependencies --configuration runtimeClasspath | grep secp256k1
|
||||
# Now shows: secp256k1-kmp-jni-jvm ✅
|
||||
|
||||
./gradlew :desktopApp:run # Should work
|
||||
```
|
||||
|
||||
### Example 3: Optimizing Build Time
|
||||
|
||||
**Current:** Clean build takes 5 minutes
|
||||
|
||||
**Steps:**
|
||||
1. **Baseline measurement:**
|
||||
```bash
|
||||
./gradlew clean build --profile
|
||||
# Check: build/reports/profile/profile-*.html
|
||||
```
|
||||
|
||||
2. **Add optimizations** to gradle.properties:
|
||||
```properties
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.jvmargs=-Xmx4g
|
||||
kotlin.incremental=true
|
||||
```
|
||||
|
||||
3. **Re-measure:**
|
||||
```bash
|
||||
./gradlew clean build --profile
|
||||
```
|
||||
|
||||
**Expected improvement:** 30-50% faster on subsequent builds (incremental builds much faster).
|
||||
|
||||
## Delegation Patterns
|
||||
|
||||
**When to delegate to other skills:**
|
||||
- **Source set architecture** (jvmAndroid pattern, expect/actual) → Use `kotlin-multiplatform` skill
|
||||
- **Compose UI issues** (composables, state management) → Use `compose-expert` skill (when available)
|
||||
- **Kotlin language issues** (Flow, sealed classes, DSLs) → Use `kotlin-expert` skill
|
||||
- **Desktop-specific features** (Window management, MenuBar, tray) → Use `desktop-expert` skill
|
||||
|
||||
**This skill handles:** Build system, dependencies, versioning, module structure, packaging, performance.
|
||||
|
||||
## Core Principles for This Build System
|
||||
|
||||
1. **Centralize versions:** Never hardcode versions in build.gradle.kts. Always use libs.versions.toml.
|
||||
|
||||
2. **Respect source set hierarchy:** Dependencies flow downward. jvmAndroid depends on commonMain, never the reverse.
|
||||
|
||||
3. **Platform-specific variants matter:** secp256k1, JNA must use correct variant per platform. Check when errors occur.
|
||||
|
||||
4. **Clean builds are expensive:** Use incremental compilation. Only clean when truly needed (source set changes, version updates).
|
||||
|
||||
5. **Compose alignment is critical:** Compose Multiplatform version must match Kotlin version. Check compatibility matrix.
|
||||
|
||||
6. **Proguard for native libs:** All JNI libraries need explicit `-keep` rules in release builds.
|
||||
|
||||
7. **Java 21 everywhere:** All modules, all targets, consistent JVM version.
|
||||
@@ -0,0 +1,214 @@
|
||||
# Build Commands Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Core Build Tasks](#core-build-tasks)
|
||||
- [Module-Specific Builds](#module-specific-builds)
|
||||
- [Desktop Tasks](#desktop-tasks)
|
||||
- [Android Tasks](#android-tasks)
|
||||
- [Testing](#testing)
|
||||
- [Analysis & Diagnostics](#analysis--diagnostics)
|
||||
- [Performance Optimization](#performance-optimization)
|
||||
|
||||
## Core Build Tasks
|
||||
|
||||
### Full Project Build
|
||||
```bash
|
||||
./gradlew build # Build all modules
|
||||
./gradlew clean build # Clean build
|
||||
./gradlew assemble # Build without tests
|
||||
```
|
||||
|
||||
### Incremental Builds
|
||||
```bash
|
||||
./gradlew :quartz:build # Build only quartz module
|
||||
./gradlew :commons:build # Build only commons module
|
||||
./gradlew :desktopApp:build # Build only desktop app
|
||||
```
|
||||
|
||||
## Module-Specific Builds
|
||||
|
||||
### Quartz (KMP Library)
|
||||
```bash
|
||||
./gradlew :quartz:build # All targets
|
||||
./gradlew :quartz:compileKotlinJvm # JVM target only
|
||||
./gradlew :quartz:compileDebugKotlinAndroid # Android target only
|
||||
./gradlew :quartz:linkDebugFrameworkIosArm64 # iOS framework
|
||||
./gradlew :quartz:publishToMavenLocal # Publish locally
|
||||
```
|
||||
|
||||
### Commons (Shared UI)
|
||||
```bash
|
||||
./gradlew :commons:build # All targets
|
||||
./gradlew :commons:compileKotlinJvm # Desktop target
|
||||
./gradlew :commons:compileDebugKotlinAndroid # Android target
|
||||
```
|
||||
|
||||
## Desktop Tasks
|
||||
|
||||
### Run Desktop App
|
||||
```bash
|
||||
./gradlew :desktopApp:run # Run desktop app
|
||||
./gradlew :desktopApp:runDistributable # Run packaged version
|
||||
```
|
||||
|
||||
### Package Desktop App
|
||||
```bash
|
||||
./gradlew :desktopApp:createDistributable # Create runnable package
|
||||
./gradlew :desktopApp:packageDmg # macOS DMG
|
||||
./gradlew :desktopApp:packageMsi # Windows MSI
|
||||
./gradlew :desktopApp:packageDeb # Linux DEB
|
||||
```
|
||||
|
||||
### Distribution Location
|
||||
- macOS: `desktopApp/build/compose/binaries/main/dmg/`
|
||||
- Windows: `desktopApp/build/compose/binaries/main/msi/`
|
||||
- Linux: `desktopApp/build/compose/binaries/main/deb/`
|
||||
|
||||
## Android Tasks
|
||||
|
||||
### Compile & Assemble
|
||||
```bash
|
||||
./gradlew :amethyst:assembleDebug # Debug APK
|
||||
./gradlew :amethyst:assembleRelease # Release APK
|
||||
./gradlew :amethyst:bundleRelease # Release AAB
|
||||
```
|
||||
|
||||
### Install & Run
|
||||
```bash
|
||||
./gradlew :amethyst:installDebug # Install debug on device
|
||||
adb shell am start -n com.vitorpamplona.amethyst/.MainActivity
|
||||
```
|
||||
|
||||
### Proguard/R8
|
||||
```bash
|
||||
./gradlew :quartz:minifyReleaseWithR8 # Test R8 minification
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
```bash
|
||||
./gradlew test # All unit tests
|
||||
./gradlew :quartz:jvmTest # JVM unit tests
|
||||
./gradlew :quartz:testDebugUnitTest # Android unit tests
|
||||
./gradlew :commons:test # Commons tests
|
||||
```
|
||||
|
||||
### Android Instrumented Tests
|
||||
```bash
|
||||
./gradlew :quartz:connectedAndroidTest # Requires device/emulator
|
||||
```
|
||||
|
||||
### Test Reports
|
||||
```bash
|
||||
# Reports location: <module>/build/reports/tests/
|
||||
open quartz/build/reports/tests/jvmTest/index.html
|
||||
```
|
||||
|
||||
## Analysis & Diagnostics
|
||||
|
||||
### Dependency Analysis
|
||||
```bash
|
||||
./gradlew dependencies # All dependencies
|
||||
./gradlew :quartz:dependencies # Quartz dependencies
|
||||
./gradlew dependencyInsight --dependency okhttp # Specific dependency
|
||||
```
|
||||
|
||||
### Build Scan
|
||||
```bash
|
||||
./gradlew build --scan # Upload to scans.gradle.com
|
||||
```
|
||||
|
||||
### Performance Profiling
|
||||
```bash
|
||||
./gradlew build --profile # Generate profile report
|
||||
# Report: build/reports/profile/profile-<timestamp>.html
|
||||
```
|
||||
|
||||
### Task Dependencies
|
||||
```bash
|
||||
./gradlew :desktopApp:run --dry-run # Show task graph
|
||||
./gradlew :desktopApp:dependencies --scan # Visualize dependencies
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Configuration Cache
|
||||
```bash
|
||||
./gradlew build --configuration-cache # Enable config cache
|
||||
./gradlew build --configuration-cache-problems=warn
|
||||
```
|
||||
|
||||
### Build Cache
|
||||
```bash
|
||||
./gradlew build --build-cache # Enable build cache
|
||||
./gradlew cleanBuildCache # Clear build cache
|
||||
```
|
||||
|
||||
### Parallel Execution
|
||||
```bash
|
||||
./gradlew build --parallel --max-workers=8 # Parallel with 8 workers
|
||||
```
|
||||
|
||||
### Daemon Management
|
||||
```bash
|
||||
./gradlew --stop # Stop Gradle daemon
|
||||
./gradlew --status # Daemon status
|
||||
```
|
||||
|
||||
### Incremental Compilation
|
||||
```bash
|
||||
# Already enabled by default in Kotlin, but can verify:
|
||||
./gradlew :quartz:compileKotlinJvm --info | grep "Incremental"
|
||||
```
|
||||
|
||||
## gradle.properties Optimizations
|
||||
|
||||
Add to `gradle.properties` for faster builds:
|
||||
|
||||
```properties
|
||||
# Daemon
|
||||
org.gradle.daemon=true
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
|
||||
|
||||
# Parallel
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=8
|
||||
|
||||
# Caching
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
# Kotlin
|
||||
kotlin.incremental=true
|
||||
kotlin.daemon.jvmargs=-Xmx2g
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Full Desktop Build & Run
|
||||
```bash
|
||||
./gradlew :desktopApp:clean :desktopApp:run
|
||||
```
|
||||
|
||||
### Quick Desktop Iteration
|
||||
```bash
|
||||
# No clean - incremental compilation
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
|
||||
### Android Release Build
|
||||
```bash
|
||||
./gradlew :amethyst:clean :amethyst:bundleRelease
|
||||
```
|
||||
|
||||
### Test All KMP Targets
|
||||
```bash
|
||||
./gradlew :quartz:test :quartz:testDebugUnitTest
|
||||
```
|
||||
|
||||
### Publish Quartz Locally for Testing
|
||||
```bash
|
||||
./gradlew :quartz:publishToMavenLocal
|
||||
# Then update version in consumer project to test
|
||||
```
|
||||
@@ -0,0 +1,643 @@
|
||||
# Common Build Errors & Solutions
|
||||
|
||||
## Table of Contents
|
||||
- [Compose Version Conflicts](#compose-version-conflicts)
|
||||
- [secp256k1 JNI Errors](#secp256k1-jni-errors)
|
||||
- [Source Set Dependency Issues](#source-set-dependency-issues)
|
||||
- [Proguard/R8 Issues](#proguardr8-issues)
|
||||
- [Desktop Packaging Errors](#desktop-packaging-errors)
|
||||
- [Kotlin Compilation Errors](#kotlin-compilation-errors)
|
||||
- [Dependency Resolution Failures](#dependency-resolution-failures)
|
||||
- [JVM/JDK Version Issues](#jvmjdk-version-issues)
|
||||
|
||||
---
|
||||
|
||||
## Compose Version Conflicts
|
||||
|
||||
### Error 1: Compose Runtime Mismatch
|
||||
|
||||
```
|
||||
java.lang.IllegalStateException: Version mismatch: Compose runtime is 1.10.0 but compiler is 1.9.0
|
||||
```
|
||||
|
||||
**Cause:** Compose Compiler plugin version doesn't match Compose Runtime
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In gradle/libs.versions.toml
|
||||
composeMultiplatform = "1.9.3" // Must align with Kotlin version
|
||||
kotlin = "2.3.0"
|
||||
|
||||
// Check compatibility matrix:
|
||||
// https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
./gradlew :commons:dependencies | grep compose
|
||||
```
|
||||
|
||||
### Error 2: AndroidX Compose BOM Conflict
|
||||
|
||||
```
|
||||
Duplicate class androidx.compose.ui.platform.AndroidCompositionLocalMap found in modules...
|
||||
```
|
||||
|
||||
**Cause:** Both Compose Multiplatform and AndroidX Compose BOM providing same classes
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In commons/build.gradle.kts (KMP module)
|
||||
// Use Compose Multiplatform, NOT AndroidX BOM
|
||||
dependencies {
|
||||
implementation(compose.ui) // ✅ Compose Multiplatform
|
||||
implementation(compose.material3)
|
||||
|
||||
// Don't use in KMP modules:
|
||||
// implementation(libs.androidx.compose.bom) // ❌ Android-only
|
||||
}
|
||||
|
||||
// In amethyst/build.gradle.kts (Android-only module)
|
||||
// Can use AndroidX BOM
|
||||
dependencies {
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
implementation(libs.androidx.ui)
|
||||
}
|
||||
```
|
||||
|
||||
### Error 3: Material3 WindowSizeClass Not Found
|
||||
|
||||
```
|
||||
Unresolved reference: WindowSizeClass
|
||||
```
|
||||
|
||||
**Cause:** Using Android's WindowSizeClass in shared KMP code
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// Don't use in commonMain or jvmAndroid:
|
||||
// import androidx.compose.material3.windowsizeclass.WindowSizeClass // ❌
|
||||
|
||||
// Use in androidMain only, or create expect/actual:
|
||||
// commonMain
|
||||
expect class WindowSizeClassAdapter
|
||||
|
||||
// androidMain
|
||||
actual typealias WindowSizeClassAdapter = androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
|
||||
// jvmMain (desktop)
|
||||
actual class WindowSizeClassAdapter { /* Custom impl */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## secp256k1 JNI Errors
|
||||
|
||||
### Error 1: JNI Library Not Found (Desktop)
|
||||
|
||||
```
|
||||
java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path
|
||||
```
|
||||
|
||||
**Cause:** Desktop using wrong secp256k1 variant (Android JNI instead of JVM JNI)
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In quartz/build.gradle.kts
|
||||
sourceSets {
|
||||
jvmMain {
|
||||
dependencies {
|
||||
// ✅ Correct - JVM variant
|
||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
||||
|
||||
// ❌ Wrong - Android variant
|
||||
// implementation(libs.secp256k1.kmp.jni.android)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
./gradlew :quartz:dependencies --configuration jvmRuntimeClasspath | grep secp256k1
|
||||
# Should show: secp256k1-kmp-jni-jvm, NOT jni-android
|
||||
```
|
||||
|
||||
### Error 2: Version Mismatch Between Variants
|
||||
|
||||
```
|
||||
java.lang.NoSuchMethodError: fr.acinq.secp256k1.Secp256k1.sign
|
||||
```
|
||||
|
||||
**Cause:** Common, Android, and JVM variants have different versions
|
||||
|
||||
**Solution:**
|
||||
```toml
|
||||
# In gradle/libs.versions.toml
|
||||
# All three MUST use same version
|
||||
secp256k1KmpJniAndroid = "0.22.0"
|
||||
|
||||
[libraries]
|
||||
secp256k1-kmp-common = { ..., version.ref = "secp256k1KmpJniAndroid" }
|
||||
secp256k1-kmp-jni-android = { ..., version.ref = "secp256k1KmpJniAndroid" }
|
||||
secp256k1-kmp-jni-jvm = { ..., version.ref = "secp256k1KmpJniAndroid" }
|
||||
```
|
||||
|
||||
### Error 3: Android JNI Not Loaded
|
||||
|
||||
```
|
||||
java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader couldn't find "libsecp256k1jni.so"
|
||||
```
|
||||
|
||||
**Cause:** Proguard stripping JNI classes
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
# In quartz/proguard-rules.pro
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Source Set Dependency Issues
|
||||
|
||||
### Error 1: jvmAndroid Defined After androidMain
|
||||
|
||||
```
|
||||
Could not get unknown property 'jvmAndroid' for source set container
|
||||
```
|
||||
|
||||
**Cause:** Source sets must be defined in dependency order
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// ✅ Correct order
|
||||
sourceSets {
|
||||
commonMain { }
|
||||
|
||||
// Define jvmAndroid BEFORE androidMain and jvmMain
|
||||
val jvmAndroid = create("jvmAndroid") {
|
||||
dependsOn(commonMain.get())
|
||||
}
|
||||
|
||||
androidMain {
|
||||
dependsOn(jvmAndroid) // Now jvmAndroid exists
|
||||
}
|
||||
|
||||
jvmMain {
|
||||
dependsOn(jvmAndroid)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error 2: Dependency in Wrong Source Set
|
||||
|
||||
```
|
||||
Unresolved reference: ObjectMapper (Jackson)
|
||||
```
|
||||
|
||||
**Cause:** JVM-only library in commonMain
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
sourceSets {
|
||||
commonMain {
|
||||
// ❌ Jackson is JVM-only, can't use here
|
||||
// implementation(libs.jackson.module.kotlin)
|
||||
}
|
||||
|
||||
val jvmAndroid = create("jvmAndroid") {
|
||||
dependsOn(commonMain.get())
|
||||
// ✅ Jackson in jvmAndroid (shared JVM code)
|
||||
api(libs.jackson.module.kotlin)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error 3: Platform-Specific Code in Shared Source Set
|
||||
|
||||
```
|
||||
java.lang.NoClassDefFoundError: android.content.Context
|
||||
```
|
||||
|
||||
**Cause:** Android-specific API in jvmAndroid or commonMain
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// Use expect/actual pattern
|
||||
|
||||
// commonMain
|
||||
expect class PlatformContext
|
||||
|
||||
// androidMain
|
||||
actual typealias PlatformContext = android.content.Context
|
||||
|
||||
// jvmMain
|
||||
actual class PlatformContext {
|
||||
// Custom desktop implementation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proguard/R8 Issues
|
||||
|
||||
### Error 1: Native Library Classes Stripped
|
||||
|
||||
```
|
||||
java.lang.NoClassDefFoundError: com.goterl.lazysodium.Sodium
|
||||
```
|
||||
|
||||
**Cause:** R8/Proguard removing JNA/LibSodium classes
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
# In quartz/proguard-rules.pro
|
||||
-keep class com.goterl.lazysodium.** { *; }
|
||||
-keep class com.sun.jna.** { *; }
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
```
|
||||
|
||||
### Error 2: Reflection-Based Libraries Broken
|
||||
|
||||
```
|
||||
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of ...
|
||||
```
|
||||
|
||||
**Cause:** Jackson uses reflection, R8 strips class metadata
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
# Preserve reflection metadata
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Signature
|
||||
-keepattributes InnerClasses
|
||||
|
||||
# Keep all Quartz event classes
|
||||
-keep class com.vitorpamplona.quartz.** { *; }
|
||||
```
|
||||
|
||||
### Error 3: Enum Values Missing
|
||||
|
||||
```
|
||||
java.lang.IllegalArgumentException: No enum constant ...
|
||||
```
|
||||
|
||||
**Cause:** R8 obfuscating enum names
|
||||
|
||||
**Solution:**
|
||||
```proguard
|
||||
# Keep all enums
|
||||
-keep enum ** { *; }
|
||||
-keepnames class ** { *; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Desktop Packaging Errors
|
||||
|
||||
### Error 1: Icon Not Found
|
||||
|
||||
```
|
||||
FAILURE: Build failed with an exception.
|
||||
* What went wrong: Cannot find icon file: src/jvmMain/resources/icon.icns
|
||||
```
|
||||
|
||||
**Cause:** Icon file missing or wrong path
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In desktopApp/build.gradle.kts
|
||||
nativeDistributions {
|
||||
macOS {
|
||||
// Ensure file exists at this path
|
||||
iconFile.set(project.file("src/jvmMain/resources/icon.icns"))
|
||||
}
|
||||
|
||||
// Check file exists:
|
||||
// ls -la desktopApp/src/jvmMain/resources/
|
||||
}
|
||||
```
|
||||
|
||||
**Icon Requirements:**
|
||||
- macOS: `.icns` (512x512, 256x256, 128x128, 32x32)
|
||||
- Windows: `.ico` (256x256, 128x128, 64x64, 32x32, 16x16)
|
||||
- Linux: `.png` (512x512 recommended)
|
||||
|
||||
### Error 2: Main Class Not Found
|
||||
|
||||
```
|
||||
Error: Could not find or load main class com.vitorpamplona.amethyst.desktop.MainKt
|
||||
```
|
||||
|
||||
**Cause:** Wrong mainClass path or Main.kt doesn't have main()
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In desktopApp/build.gradle.kts
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
|
||||
// ^^^^
|
||||
// Kotlin compiler adds "Kt" suffix
|
||||
}
|
||||
}
|
||||
|
||||
// In src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
|
||||
fun main() = application {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Error 3: Native Library Missing in Package
|
||||
|
||||
```
|
||||
java.lang.UnsatisfiedLinkError: no secp256k1jni in java.library.path
|
||||
```
|
||||
|
||||
**Cause:** Native libraries not bundled in distribution
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// Native libs are automatically included via dependencies
|
||||
// Verify secp256k1-kmp-jni-jvm is in dependencies:
|
||||
dependencies {
|
||||
implementation(libs.secp256k1.kmp.jni.jvm) // ✅ Includes native libs
|
||||
}
|
||||
|
||||
// Test packaged app:
|
||||
./gradlew :desktopApp:createDistributable
|
||||
# Run from: desktopApp/build/compose/binaries/main/app/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kotlin Compilation Errors
|
||||
|
||||
### Error 1: Expect/Actual Mismatch
|
||||
|
||||
```
|
||||
'actual' declaration has no corresponding expected declaration
|
||||
```
|
||||
|
||||
**Cause:** Signature mismatch or missing expect
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// commonMain - expect declaration
|
||||
expect class CryptoProvider {
|
||||
fun sign(message: ByteArray, privateKey: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
// androidMain & jvmMain - actual must match EXACTLY
|
||||
actual class CryptoProvider {
|
||||
actual fun sign(message: ByteArray, privateKey: ByteArray): ByteArray {
|
||||
// Implementation
|
||||
}
|
||||
}
|
||||
|
||||
// Common mistakes:
|
||||
// - Different parameter names ❌
|
||||
// - Different return types ❌
|
||||
// - Missing 'actual' modifier ❌
|
||||
```
|
||||
|
||||
### Error 2: Target JVM Version Mismatch
|
||||
|
||||
```
|
||||
Compilation failed: module was compiled with an incompatible version of Kotlin
|
||||
```
|
||||
|
||||
**Cause:** Different JVM targets across modules
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// Ensure ALL modules use same JVM target
|
||||
|
||||
// In quartz/build.gradle.kts
|
||||
kotlin {
|
||||
jvm {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21) // ✅ Java 21
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In android {} block
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
```
|
||||
|
||||
### Error 3: Compose Compiler Plugin Missing
|
||||
|
||||
```
|
||||
This declaration needs opt-in. Please use @OptIn(ComposeApi::class) or @Composable
|
||||
```
|
||||
|
||||
**Cause:** Compose compiler plugin not applied
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In build.gradle.kts
|
||||
plugins {
|
||||
alias(libs.plugins.jetbrainsComposeCompiler) // ✅ Add this
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Resolution Failures
|
||||
|
||||
### Error 1: Repository Not Found
|
||||
|
||||
```
|
||||
Could not find com.github.vitorpamplona.compose-richtext:richtext-ui:f92ef49c9d
|
||||
```
|
||||
|
||||
**Cause:** Jitpack or custom Maven repository not configured
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In settings.gradle
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = "https://jitpack.io" } // ✅ Add Jitpack
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error 2: Gradle Version Too Old
|
||||
|
||||
```
|
||||
Version catalogs are not supported in this version of Gradle
|
||||
```
|
||||
|
||||
**Cause:** Gradle < 7.0
|
||||
|
||||
**Solution:**
|
||||
```properties
|
||||
# In gradle/wrapper/gradle-wrapper.properties
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
```
|
||||
|
||||
Then: `./gradlew wrapper --gradle-version=8.9`
|
||||
|
||||
### Error 3: Dependency Variant Not Found
|
||||
|
||||
```
|
||||
No matching variant of fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.22.0 was found
|
||||
```
|
||||
|
||||
**Cause:** Wrong dependency configuration for target
|
||||
|
||||
**Solution:**
|
||||
```kotlin
|
||||
// In androidMain (Android library module)
|
||||
dependencies {
|
||||
// For AAR packaging
|
||||
implementation("net.java.dev.jna:jna:5.18.1@aar") // ✅ Specify @aar
|
||||
|
||||
// secp256k1 works without @aar (auto-detects)
|
||||
api(libs.secp256k1.kmp.jni.android)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JVM/JDK Version Issues
|
||||
|
||||
### Error 1: Unsupported Class File Version
|
||||
|
||||
```
|
||||
Unsupported class file major version 65
|
||||
```
|
||||
|
||||
**Cause:** Compiled with Java 21, running with older Java
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check Java version
|
||||
java -version # Should show 21
|
||||
|
||||
# Set JAVA_HOME if needed
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
|
||||
|
||||
# Or in gradle.properties
|
||||
org.gradle.java.home=/path/to/jdk-21
|
||||
```
|
||||
|
||||
### Error 2: JVM Toolchain Not Found
|
||||
|
||||
```
|
||||
No matching toolchain found for requested JvmVersion
|
||||
```
|
||||
|
||||
**Cause:** Java 21 not installed or not detected
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install openjdk@21
|
||||
|
||||
# Ubuntu
|
||||
sudo apt install openjdk-21-jdk
|
||||
|
||||
# Set JAVA_HOME
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
|
||||
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk # Linux
|
||||
|
||||
# Verify
|
||||
./gradlew -version
|
||||
```
|
||||
|
||||
### Error 3: Gradle Daemon Using Wrong Java
|
||||
|
||||
```
|
||||
Daemon will be stopped at the end of the build because JVM version has changed
|
||||
```
|
||||
|
||||
**Cause:** Daemon started with different Java version
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Stop all daemons
|
||||
./gradlew --stop
|
||||
|
||||
# Start with correct JAVA_HOME
|
||||
export JAVA_HOME=/path/to/jdk-21
|
||||
./gradlew build
|
||||
|
||||
# Or set in gradle.properties permanently
|
||||
org.gradle.java.home=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## General Troubleshooting Steps
|
||||
|
||||
### Step 1: Clean Build
|
||||
```bash
|
||||
./gradlew clean
|
||||
./gradlew --stop # Stop daemon
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
### Step 2: Check Dependencies
|
||||
```bash
|
||||
./gradlew :moduleName:dependencies
|
||||
./gradlew dependencyInsight --dependency libraryName
|
||||
```
|
||||
|
||||
### Step 3: Enable Debug Logging
|
||||
```bash
|
||||
./gradlew build --info # Info logging
|
||||
./gradlew build --debug # Debug logging (verbose)
|
||||
./gradlew build --stacktrace
|
||||
```
|
||||
|
||||
### Step 4: Invalidate Caches
|
||||
```bash
|
||||
# Clear Gradle cache
|
||||
rm -rf ~/.gradle/caches/
|
||||
|
||||
# Clear build outputs
|
||||
./gradlew clean
|
||||
|
||||
# Clear Gradle wrapper cache
|
||||
rm -rf ~/.gradle/wrapper/
|
||||
```
|
||||
|
||||
### Step 5: Build Scan
|
||||
```bash
|
||||
./gradlew build --scan
|
||||
# Opens interactive diagnostics in browser
|
||||
```
|
||||
|
||||
## Quick Reference: Error Keywords → Solution
|
||||
|
||||
| Error Keyword | Likely Cause | Quick Fix |
|
||||
|---------------|--------------|-----------|
|
||||
| `UnsatisfiedLinkError` | Wrong JNI variant | Check secp256k1/JNA variants by platform |
|
||||
| `IllegalStateException` (Compose) | Version mismatch | Align Compose Multiplatform + Kotlin versions |
|
||||
| `NoClassDefFoundError` | Proguard stripping | Add `-keep` rule for class |
|
||||
| `Unresolved reference` | Wrong source set | Move to appropriate source set (jvmAndroid) |
|
||||
| `Duplicate class` | BOM conflict | Remove AndroidX BOM from KMP modules |
|
||||
| `Version mismatch` | Plugin/runtime version mismatch | Update libs.versions.toml |
|
||||
| `No matching variant` | Repository or packaging issue | Add repository or @aar suffix |
|
||||
| `Could not find` (dependency) | Missing repository | Add maven/jitpack to repositories |
|
||||
| `Unsupported class file` | Java version mismatch | Update JAVA_HOME to Java 21 |
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
1. **Check Build Scan**: `./gradlew build --scan` for detailed diagnostics
|
||||
2. **Gradle Forums**: https://discuss.gradle.org/
|
||||
3. **Kotlin Slack**: #multiplatform channel
|
||||
4. **Stack Overflow**: Tags `gradle`, `kotlin-multiplatform`, `compose-multiplatform`
|
||||
@@ -0,0 +1,266 @@
|
||||
# Module Dependency Graph
|
||||
|
||||
## Visual Hierarchy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Root Project │
|
||||
│ (Amethyst) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┬────────────┐
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐
|
||||
│ :amethyst │ │ :desktopApp │ │ :benchmark │ │:ammolite │
|
||||
│ (Android) │ │ (JVM) │ │ (Android) │ │ (Support)│
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └──────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
└────────────────┼────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :commons │
|
||||
│ (KMP UI) │
|
||||
│ │
|
||||
│ jvmAndroid │
|
||||
│ / \ │
|
||||
│ jvm android│
|
||||
└─────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ :quartz │
|
||||
│(KMP Library)│
|
||||
│ │
|
||||
│ commonMain │
|
||||
│ │ │
|
||||
│ jvmAndroid │
|
||||
│ / | \ │
|
||||
│jvm and ios │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Module Details
|
||||
|
||||
### :quartz (KMP Nostr Library)
|
||||
**Type:** Kotlin Multiplatform Library
|
||||
**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64)
|
||||
**Dependencies:**
|
||||
- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable
|
||||
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain
|
||||
|
||||
**Role:** Core Nostr protocol implementation, shared across all platforms
|
||||
|
||||
### :commons (Shared UI Components)
|
||||
**Type:** Kotlin Multiplatform Library
|
||||
**Targets:** JVM, Android
|
||||
**Dependencies:**
|
||||
- Module: `:quartz`
|
||||
- External: Compose Multiplatform, Material3, kotlinx.collections.immutable
|
||||
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}
|
||||
|
||||
**Role:** Shared Compose UI components for Desktop and Android
|
||||
|
||||
### :desktopApp (Desktop Application)
|
||||
**Type:** JVM Application
|
||||
**Targets:** JVM (Desktop)
|
||||
**Dependencies:**
|
||||
- Modules: `:commons`, `:quartz`
|
||||
- External: Compose Desktop, kotlinx.coroutines.swing
|
||||
|
||||
**Role:** Desktop-specific navigation, layouts, and entry point
|
||||
|
||||
### :amethyst (Android Application)
|
||||
**Type:** Android Application
|
||||
**Targets:** Android
|
||||
**Dependencies:**
|
||||
- Modules: `:commons`, `:quartz`, `:ammolite`
|
||||
- External: Android SDK, AndroidX, Firebase, Tor
|
||||
|
||||
**Role:** Android-specific navigation, layouts, and entry point
|
||||
|
||||
### :benchmark (Android Benchmark)
|
||||
**Type:** Android Library
|
||||
**Targets:** Android
|
||||
**Dependencies:**
|
||||
- Modules: `:commons`, `:quartz`
|
||||
- External: AndroidX Benchmark
|
||||
|
||||
**Role:** Performance benchmarking for Android builds
|
||||
|
||||
### :ammolite (Support Module)
|
||||
**Type:** Android Library
|
||||
**Targets:** Android
|
||||
**Dependencies:** Android-specific utilities
|
||||
|
||||
**Role:** Android support utilities for amethyst
|
||||
|
||||
## Dependency Flow Patterns
|
||||
|
||||
### Desktop Build Chain
|
||||
```
|
||||
:desktopApp → :commons (jvmMain) → :quartz (jvmMain)
|
||||
↓
|
||||
jvmAndroid
|
||||
↓
|
||||
commonMain
|
||||
```
|
||||
|
||||
### Android Build Chain
|
||||
```
|
||||
:amethyst → :commons (androidMain) → :quartz (androidMain)
|
||||
↓ ↓
|
||||
:ammolite jvmAndroid
|
||||
↓
|
||||
commonMain
|
||||
```
|
||||
|
||||
## Source Set Dependencies
|
||||
|
||||
### :quartz Source Sets
|
||||
```
|
||||
commonMain (base)
|
||||
├─ jvmAndroid (shared JVM code)
|
||||
│ ├─ androidMain (Android platform)
|
||||
│ └─ jvmMain (Desktop platform)
|
||||
└─ iosMain (iOS platform)
|
||||
├─ iosX64Main
|
||||
├─ iosArm64Main
|
||||
└─ iosSimulatorArm64Main
|
||||
```
|
||||
|
||||
**Key Dependencies per Source Set:**
|
||||
- **commonMain**: secp256k1-kmp, kotlinx.coroutines, collection, immutable collections
|
||||
- **jvmAndroid**: jackson, okhttp, url-detector, rfc3986
|
||||
- **androidMain**: secp256k1-kmp-jni-android, lazysodium-android, jna (aar)
|
||||
- **jvmMain**: secp256k1-kmp-jni-jvm, lazysodium-java, jna (jar)
|
||||
|
||||
### :commons Source Sets
|
||||
```
|
||||
commonMain (base UI)
|
||||
└─ jvmAndroid (shared JVM UI)
|
||||
├─ androidMain (Android UI utilities)
|
||||
└─ jvmMain (Desktop UI utilities)
|
||||
```
|
||||
|
||||
**Key Dependencies per Source Set:**
|
||||
- **commonMain**: Compose Multiplatform, Material3, :quartz
|
||||
- **jvmAndroid**: url-detector
|
||||
- **androidMain**: AndroidX Compose tooling
|
||||
- **jvmMain**: Compose Desktop
|
||||
|
||||
## Critical Dependency Patterns
|
||||
|
||||
### 1. secp256k1 Variants
|
||||
```kotlin
|
||||
// commonMain - API only
|
||||
api(libs.secp256k1.kmp.common)
|
||||
|
||||
// androidMain - JNI Android
|
||||
api(libs.secp256k1.kmp.jni.android)
|
||||
|
||||
// jvmMain - JNI JVM
|
||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
||||
```
|
||||
**Why:** Different JNI bindings for Android vs Desktop JVM
|
||||
|
||||
### 2. JNA Variants (for LibSodium)
|
||||
```kotlin
|
||||
// androidMain
|
||||
implementation("com.goterl:lazysodium-android:5.2.0@aar")
|
||||
implementation("net.java.dev.jna:jna:5.18.1@aar")
|
||||
|
||||
// jvmMain
|
||||
implementation(libs.lazysodium.java)
|
||||
implementation(libs.jna) // JAR variant
|
||||
```
|
||||
**Why:** Android needs AAR packaging, JVM needs JAR
|
||||
|
||||
### 3. Compose Alignment
|
||||
```kotlin
|
||||
// commons/build.gradle.kts
|
||||
implementation(compose.ui) // Compose Multiplatform BOM
|
||||
implementation(compose.material3)
|
||||
|
||||
// Version catalog alignment
|
||||
composeMultiplatform = "1.9.3"
|
||||
composeBom = "2025.12.01" // AndroidX Compose
|
||||
```
|
||||
**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align
|
||||
|
||||
## Dependency Configuration Types
|
||||
|
||||
### API vs Implementation
|
||||
|
||||
**Use `api` when:**
|
||||
- Dependency types appear in module's public API
|
||||
- Used in expect/actual declarations visible to consumers
|
||||
- Example: `secp256k1-kmp-common` in quartz (public types)
|
||||
|
||||
**Use `implementation` when:**
|
||||
- Internal implementation detail
|
||||
- Not exposed to module consumers
|
||||
- Example: `okhttp` in quartz (internal network client)
|
||||
|
||||
### Example from quartz
|
||||
```kotlin
|
||||
// Public API - exposed to consumers
|
||||
api(libs.secp256k1.kmp.common)
|
||||
api(libs.jackson.module.kotlin) // Event serialization public
|
||||
|
||||
// Internal implementation
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
```
|
||||
|
||||
## Transitive Dependency Impact
|
||||
|
||||
### When :desktopApp depends on :commons
|
||||
- Gets `:quartz` transitively (via :commons)
|
||||
- Gets `secp256k1-kmp-jvm` transitively (via :quartz jvmMain)
|
||||
- Does NOT get Android-specific dependencies (scoped to androidMain)
|
||||
|
||||
### When :amethyst depends on :commons
|
||||
- Gets `:quartz` transitively (via :commons)
|
||||
- Gets `secp256k1-kmp-jni-android` transitively (via :quartz androidMain)
|
||||
- Does NOT get JVM/Desktop-specific dependencies (scoped to jvmMain)
|
||||
|
||||
## Verifying Dependencies
|
||||
|
||||
### Check Module Dependencies
|
||||
```bash
|
||||
./gradlew :desktopApp:dependencies
|
||||
./gradlew :amethyst:dependencies
|
||||
```
|
||||
|
||||
### Check Specific Library
|
||||
```bash
|
||||
./gradlew dependencyInsight --dependency secp256k1
|
||||
./gradlew dependencyInsight --dependency compose-ui
|
||||
```
|
||||
|
||||
### Visualize with Build Scan
|
||||
```bash
|
||||
./gradlew :desktopApp:dependencies --scan
|
||||
# Opens interactive dependency graph in browser
|
||||
```
|
||||
|
||||
## Common Dependency Issues
|
||||
|
||||
### Issue 1: Wrong secp256k1 Variant in Desktop
|
||||
**Symptom:** `UnsatisfiedLinkError: no secp256k1jni in java.library.path`
|
||||
**Cause:** Desktop using Android JNI variant
|
||||
**Fix:** Ensure jvmMain uses `secp256k1-kmp-jni-jvm`
|
||||
|
||||
### Issue 2: Compose Version Mismatch
|
||||
**Symptom:** `IllegalStateException: Version mismatch`
|
||||
**Cause:** Compose Multiplatform plugin vs runtime version mismatch
|
||||
**Fix:** Align `composeMultiplatform` version in libs.versions.toml with Kotlin plugin
|
||||
|
||||
### Issue 3: Duplicate JNA Classes
|
||||
**Symptom:** `DuplicateClassException: com.sun.jna.Native`
|
||||
**Cause:** Both JAR and AAR JNA variants in classpath
|
||||
**Fix:** Use AAR (@aar) in androidMain, JAR in jvmMain (never in shared source sets)
|
||||
@@ -0,0 +1,422 @@
|
||||
# Version Catalog Guide
|
||||
|
||||
## Overview
|
||||
|
||||
AmethystMultiplatform uses Gradle's version catalog (`gradle/libs.versions.toml`) to centralize dependency management. This ensures version consistency across all modules and simplifies updates.
|
||||
|
||||
## Structure
|
||||
|
||||
### sections
|
||||
```toml
|
||||
[versions] # Version numbers (referenced by libraries and plugins)
|
||||
[libraries] # Library dependencies
|
||||
[plugins] # Gradle plugins
|
||||
```
|
||||
|
||||
## Version References
|
||||
|
||||
### Defining Versions
|
||||
```toml
|
||||
[versions]
|
||||
kotlin = "2.3.0"
|
||||
composeMultiplatform = "1.9.3"
|
||||
okhttp = "5.3.2"
|
||||
```
|
||||
|
||||
### Special Patterns
|
||||
|
||||
#### Android SDK Versions
|
||||
```toml
|
||||
android-compileSdk = "36"
|
||||
android-minSdk = "26"
|
||||
android-targetSdk = "36"
|
||||
```
|
||||
**Access in build.gradle.kts:**
|
||||
```kotlin
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
```
|
||||
|
||||
#### Version Suffixes (Git Commits)
|
||||
```toml
|
||||
androidKotlinGeohash = "b481c6a64e" # Jitpack commit hash
|
||||
markdown = "f92ef49c9d"
|
||||
```
|
||||
**Why:** For GitHub dependencies via Jitpack that don't have semantic versions
|
||||
|
||||
## Library Declarations
|
||||
|
||||
### Basic Pattern
|
||||
```toml
|
||||
[libraries]
|
||||
library-name = { group = "...", name = "...", version.ref = "..." }
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
#### Version Reference
|
||||
```toml
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
```
|
||||
|
||||
#### Module Reference (for multi-artifact libs)
|
||||
```toml
|
||||
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidxCamera" }
|
||||
```
|
||||
|
||||
#### Without Group (shorthand)
|
||||
```toml
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
```
|
||||
**Note:** Inherits version from BOM (compose-bom)
|
||||
|
||||
### BOMs (Bill of Materials)
|
||||
|
||||
#### AndroidX Compose BOM
|
||||
```toml
|
||||
[versions]
|
||||
composeBom = "2025.12.01"
|
||||
|
||||
[libraries]
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
```
|
||||
|
||||
**Usage in build.gradle.kts:**
|
||||
```kotlin
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
implementation(libs.androidx.ui) // Version from BOM
|
||||
implementation(libs.androidx.material3) // Version from BOM
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- All AndroidX Compose artifacts use compatible versions
|
||||
- Update single BOM version, not individual libraries
|
||||
- Prevents version conflicts
|
||||
|
||||
### Platform-Specific Variants
|
||||
|
||||
#### secp256k1 (KMP crypto library)
|
||||
```toml
|
||||
secp256k1KmpJniAndroid = "0.22.0"
|
||||
|
||||
[libraries]
|
||||
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
|
||||
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
|
||||
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
|
||||
```
|
||||
|
||||
**Critical:** All three variants MUST share the same version
|
||||
|
||||
#### JNA (for LibSodium)
|
||||
```toml
|
||||
jna = "5.18.1"
|
||||
|
||||
[libraries]
|
||||
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
|
||||
```
|
||||
|
||||
**Usage in build.gradle.kts:**
|
||||
```kotlin
|
||||
// androidMain - AAR packaging
|
||||
implementation("net.java.dev.jna:jna:5.18.1@aar")
|
||||
|
||||
// jvmMain - JAR packaging
|
||||
implementation(libs.jna)
|
||||
```
|
||||
|
||||
**Why:** Android needs AAR, JVM needs JAR (different artifact types)
|
||||
|
||||
## Plugin Declarations
|
||||
|
||||
### Basic Pattern
|
||||
```toml
|
||||
[plugins]
|
||||
plugin-id = { id = "...", version.ref = "..." }
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
#### Kotlin Plugins
|
||||
```toml
|
||||
[versions]
|
||||
kotlin = "2.3.0"
|
||||
|
||||
[plugins]
|
||||
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlinxSerializationPlugin' }
|
||||
```
|
||||
|
||||
**Critical:** All Kotlin plugins MUST use the same Kotlin version
|
||||
|
||||
#### Android Gradle Plugin
|
||||
```toml
|
||||
[versions]
|
||||
agp = "8.13.2"
|
||||
|
||||
[plugins]
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
||||
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
||||
```
|
||||
|
||||
#### Compose Multiplatform
|
||||
```toml
|
||||
[versions]
|
||||
composeMultiplatform = "1.9.3"
|
||||
|
||||
[plugins]
|
||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||
```
|
||||
|
||||
### Plugin Application
|
||||
|
||||
```kotlin
|
||||
// In build.gradle.kts
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidLibrary)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
}
|
||||
```
|
||||
|
||||
## Usage in Build Files
|
||||
|
||||
### Accessing Versions
|
||||
```kotlin
|
||||
// Direct version access
|
||||
val kotlinVersion = libs.versions.kotlin.get()
|
||||
val minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
```
|
||||
|
||||
### Accessing Libraries
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
api(libs.secp256k1.kmp.common)
|
||||
implementation(libs.okhttp)
|
||||
}
|
||||
```
|
||||
|
||||
### Accessing Plugins
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidLibrary)
|
||||
}
|
||||
```
|
||||
|
||||
## Version Catalog Benefits
|
||||
|
||||
### 1. Centralized Version Management
|
||||
Update once, applies everywhere:
|
||||
```toml
|
||||
# Change one line
|
||||
kotlin = "2.3.0" → "2.4.0"
|
||||
|
||||
# Affects all usages
|
||||
- kotlinMultiplatform plugin
|
||||
- jetbrainsKotlinAndroid plugin
|
||||
- kotlin-stdlib
|
||||
- All Kotlin-related dependencies
|
||||
```
|
||||
|
||||
### 2. Type-Safe Accessors
|
||||
```kotlin
|
||||
// Compile-time checked
|
||||
implementation(libs.okhttp) // ✅ IDE autocomplete
|
||||
|
||||
// vs string-based (error-prone)
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2") // ❌ No autocomplete
|
||||
```
|
||||
|
||||
### 3. Dependency Consistency
|
||||
```kotlin
|
||||
// All modules reference same catalog
|
||||
:quartz → libs.okhttp
|
||||
:commons → libs.okhttp
|
||||
:desktopApp → libs.okhttp
|
||||
|
||||
// Same version everywhere
|
||||
```
|
||||
|
||||
### 4. Gradle Sync Improvements
|
||||
- Faster IDE sync (pre-parsed catalog)
|
||||
- Better dependency resolution
|
||||
- Clearer error messages
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### GitHub Dependencies (Jitpack)
|
||||
```toml
|
||||
[versions]
|
||||
markdown = "f92ef49c9d" # Git commit hash
|
||||
|
||||
[libraries]
|
||||
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
|
||||
```
|
||||
|
||||
**Repository config** (in settings.gradle):
|
||||
```kotlin
|
||||
repositories {
|
||||
maven { url = "https://jitpack.io" }
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Artifact Libraries
|
||||
```toml
|
||||
[versions]
|
||||
media3 = "1.9.0"
|
||||
|
||||
[libraries]
|
||||
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
||||
```
|
||||
|
||||
**Why:** All media3 artifacts share same version for compatibility
|
||||
|
||||
### Test Dependencies
|
||||
```toml
|
||||
[libraries]
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
|
||||
```
|
||||
|
||||
## Version Update Strategy
|
||||
|
||||
### Check for Updates
|
||||
```bash
|
||||
# Using Gradle Versions Plugin (if installed)
|
||||
./gradlew dependencyUpdates
|
||||
|
||||
# Manual check
|
||||
# Browse to Maven Central for specific library
|
||||
```
|
||||
|
||||
### Update Process
|
||||
1. **Update version in catalog**
|
||||
```toml
|
||||
okhttp = "5.3.2" → "5.4.0"
|
||||
```
|
||||
|
||||
2. **Test locally**
|
||||
```bash
|
||||
./gradlew clean build
|
||||
```
|
||||
|
||||
3. **Check for breaking changes**
|
||||
- Review library changelog
|
||||
- Run full test suite
|
||||
|
||||
4. **Commit with clear message**
|
||||
```
|
||||
chore: update okhttp 5.3.2 → 5.4.0
|
||||
```
|
||||
|
||||
### Critical Version Alignments
|
||||
|
||||
#### Kotlin Ecosystem
|
||||
```toml
|
||||
kotlin = "2.3.0"
|
||||
kotlinxCoroutinesCore = "1.10.2"
|
||||
kotlinxSerialization = "1.9.0"
|
||||
```
|
||||
**Rule:** Kotlin version must be compatible with kotlinx libraries
|
||||
|
||||
#### Compose Ecosystem
|
||||
```toml
|
||||
composeMultiplatform = "1.9.3"
|
||||
composeBom = "2025.12.01"
|
||||
kotlin = "2.3.0"
|
||||
```
|
||||
**Rule:** Compose Multiplatform → Kotlin version (see compatibility matrix)
|
||||
|
||||
#### AGP & Gradle
|
||||
```toml
|
||||
agp = "8.13.2"
|
||||
# Requires Gradle 8.9+
|
||||
```
|
||||
**Rule:** AGP version dictates minimum Gradle version
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue 1: Unresolved Reference
|
||||
**Error:** `Unresolved reference: libs`
|
||||
|
||||
**Cause:** Gradle version < 7.0 (version catalogs not supported)
|
||||
|
||||
**Fix:** Upgrade Gradle in `gradle/wrapper/gradle-wrapper.properties`
|
||||
|
||||
### Issue 2: Library Not Found
|
||||
**Error:** `Could not find com.example:library:1.0.0`
|
||||
|
||||
**Cause:** Repository not configured or typo in catalog
|
||||
|
||||
**Fix:**
|
||||
1. Check repository in settings.gradle
|
||||
2. Verify group/name/version in libs.versions.toml
|
||||
|
||||
### Issue 3: Version Conflict
|
||||
**Error:** `Conflict with dependency ... and ...`
|
||||
|
||||
**Cause:** Different versions of same library via transitive dependencies
|
||||
|
||||
**Fix:**
|
||||
```kotlin
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force(libs.okhttp.get().toString())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Naming Conventions
|
||||
```toml
|
||||
# Hyphen-separated, hierarchical
|
||||
androidx-compose-ui
|
||||
androidx-compose-material3
|
||||
kotlinx-coroutines-core
|
||||
|
||||
# Platform suffixes
|
||||
secp256k1-kmp-jni-android
|
||||
secp256k1-kmp-jni-jvm
|
||||
```
|
||||
|
||||
### 2. Group Related Dependencies
|
||||
```toml
|
||||
# Camera APIs together
|
||||
androidx-camera-core
|
||||
androidx-camera-camera2
|
||||
androidx-camera-view
|
||||
```
|
||||
|
||||
### 3. Document Special Cases
|
||||
```toml
|
||||
# JNA requires @aar for Android (see build.gradle.kts)
|
||||
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
|
||||
```
|
||||
|
||||
### 4. Keep BOMs Updated
|
||||
```toml
|
||||
# Update BOM, individual libs follow
|
||||
composeBom = "2025.12.01" # Latest stable
|
||||
```
|
||||
|
||||
### 5. Test Version Updates
|
||||
```bash
|
||||
# Before committing
|
||||
./gradlew :quartz:test
|
||||
./gradlew :commons:test
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Analyze Gradle build performance and generate report
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_ROOT="${1:-.}"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "🔍 Analyzing Gradle build performance..."
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Clean build for accurate timing
|
||||
echo "Running clean build with --profile..."
|
||||
./gradlew clean build --profile --scan
|
||||
|
||||
# Find the latest profile report
|
||||
PROFILE_REPORT=$(find build/reports/profile -name "*.html" -type f -printf '%T@ %p\n' | sort -n | tail -1 | cut -f2- -d" ")
|
||||
|
||||
if [ -n "$PROFILE_REPORT" ]; then
|
||||
echo ""
|
||||
echo "✅ Profile report generated: $PROFILE_REPORT"
|
||||
echo ""
|
||||
echo "📊 Build Performance Summary:"
|
||||
echo "----------------------------"
|
||||
|
||||
# Extract key metrics if available
|
||||
if command -v jq &> /dev/null && [ -f "build/reports/profile/profile.json" ]; then
|
||||
jq -r '.buildTime, .taskExecutionTime' build/reports/profile/profile.json
|
||||
else
|
||||
echo "Open the HTML report for detailed analysis:"
|
||||
echo "file://$PWD/$PROFILE_REPORT"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Profile report not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "💡 Build optimization tips:"
|
||||
echo "- Enable Gradle daemon: org.gradle.daemon=true"
|
||||
echo "- Parallel execution: org.gradle.parallel=true"
|
||||
echo "- Configuration cache: org.gradle.configuration-cache=true"
|
||||
echo "- Build cache: org.gradle.caching=true"
|
||||
echo ""
|
||||
echo "Add these to gradle.properties for faster builds"
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# Diagnose and suggest fixes for common dependency conflicts
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_ROOT="${1:-.}"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "🔍 Analyzing dependency conflicts..."
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Run dependency report
|
||||
echo "Generating dependency insight report..."
|
||||
./gradlew dependencies --configuration runtimeClasspath > /tmp/gradle-dependencies.txt 2>&1 || true
|
||||
|
||||
# Check for common conflict patterns
|
||||
echo ""
|
||||
echo "🔎 Checking for common issues:"
|
||||
echo "------------------------------"
|
||||
|
||||
# Check 1: Compose version conflicts
|
||||
if grep -q "compose" /tmp/gradle-dependencies.txt; then
|
||||
echo "✓ Compose dependencies found"
|
||||
echo " Tip: Ensure Compose Multiplatform and AndroidX Compose versions align"
|
||||
echo " Current project uses:"
|
||||
echo " - Compose Multiplatform BOM"
|
||||
echo " - AndroidX Compose BOM"
|
||||
fi
|
||||
|
||||
# Check 2: secp256k1 variants
|
||||
if grep -q "secp256k1" /tmp/gradle-dependencies.txt; then
|
||||
echo "✓ secp256k1 dependencies found"
|
||||
echo " Ensure correct variant:"
|
||||
echo " - Android: secp256k1-kmp-jni-android"
|
||||
echo " - JVM/Desktop: secp256k1-kmp-jni-jvm"
|
||||
echo " - Common: secp256k1-kmp (transitive)"
|
||||
fi
|
||||
|
||||
# Check 3: Kotlin version alignment
|
||||
KOTLIN_VERSION=$(grep "kotlin =" gradle/libs.versions.toml | cut -d'"' -f2)
|
||||
echo "✓ Kotlin version: $KOTLIN_VERSION"
|
||||
echo " All Kotlin plugins should use the same version"
|
||||
|
||||
# Check 4: Multiple versions of same library
|
||||
echo ""
|
||||
echo "🔍 Checking for version conflicts..."
|
||||
./gradlew dependencyInsight --configuration runtimeClasspath --dependency okhttp || true
|
||||
|
||||
echo ""
|
||||
echo "💡 Common fixes:"
|
||||
echo "---------------"
|
||||
echo "1. Compose conflicts:"
|
||||
echo " - Align compose-multiplatform plugin version with runtime"
|
||||
echo " - Use BOM for AndroidX Compose to enforce consistency"
|
||||
echo ""
|
||||
echo "2. secp256k1 conflicts:"
|
||||
echo " - Use 'api' instead of 'implementation' in source sets"
|
||||
echo " - Ensure androidMain uses jni-android, jvmMain uses jni-jvm"
|
||||
echo ""
|
||||
echo "3. Kotlin version conflicts:"
|
||||
echo " - Update all kotlin plugins to same version in libs.versions.toml"
|
||||
echo " - Check for transitive Kotlin dependencies"
|
||||
echo ""
|
||||
echo "Run './gradlew dependencyInsight --dependency <name>' for specific conflicts"
|
||||
@@ -0,0 +1,551 @@
|
||||
---
|
||||
name: nostr-expert
|
||||
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
|
||||
---
|
||||
|
||||
# Nostr Protocol Expert (Quartz Implementation)
|
||||
|
||||
Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP Nostr library.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Implementing Nostr event types (TextNote, Reaction, Zap, etc.)
|
||||
- Creating/parsing events with TagArrayBuilder DSL
|
||||
- Working with event kinds and tags
|
||||
- Finding NIP implementations in quartz/ codebase
|
||||
- Nostr cryptography (secp256k1 signing, NIP-44 encryption)
|
||||
- Bech32 encoding/decoding (npub, nsec, note formats)
|
||||
- Event validation and verification
|
||||
|
||||
**For NIP specifications** → Use `nostr-protocol` agent
|
||||
**For Quartz implementation** → Use this skill
|
||||
|
||||
## Quartz Architecture
|
||||
|
||||
Quartz organizes code by NIP number:
|
||||
|
||||
```
|
||||
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/
|
||||
├── nip01Core/ # Core protocol (Event, Kind, Tags)
|
||||
├── nip04Dm/ # Legacy DMs (deprecated)
|
||||
├── nip10Notes/ # Text notes with threading
|
||||
├── nip17Dm/ # Private DMs (gift wrap)
|
||||
├── nip19Bech32/ # Bech32 encoding
|
||||
├── nip44Encryption/ # Modern encryption (ChaCha20)
|
||||
├── nip57Zaps/ # Lightning zaps
|
||||
├── ... (57 NIPs total)
|
||||
└── experimental/ # Draft NIPs
|
||||
```
|
||||
|
||||
**Pattern**: `nip##<Name>/` directories contain event classes, tags, and utilities for that NIP.
|
||||
|
||||
**Find implementations**: Use `scripts/nip-lookup.sh <nip-number>` or see `references/nip-catalog.md`.
|
||||
|
||||
## Event Anatomy
|
||||
|
||||
### Core Structure
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
open class Event(
|
||||
val id: HexKey, // SHA-256 hash of serialized event
|
||||
val pubKey: HexKey, // Author's public key (32 bytes hex)
|
||||
val createdAt: Long, // Unix timestamp
|
||||
val kind: Kind, // Event kind (Int typealias)
|
||||
val tags: TagArray, // Array of tag arrays
|
||||
val content: String, // Event content
|
||||
val sig: HexKey, // Schnorr signature (64 bytes hex)
|
||||
) : IEvent
|
||||
```
|
||||
|
||||
**Key insight**: `Event` is the base class. Specific event types (TextNoteEvent, ReactionEvent) extend it and add parsing/helper methods.
|
||||
|
||||
### Kind Classification
|
||||
|
||||
```kotlin
|
||||
typealias Kind = Int
|
||||
|
||||
fun Kind.isEphemeral() = this in 20000..29999 // Not stored
|
||||
fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999
|
||||
fun Kind.isAddressable() = this in 30000..39999 // Replaceable + has d-tag
|
||||
fun Kind.isRegular() = this in 1000..9999 // Stored, not replaced
|
||||
```
|
||||
|
||||
**Pattern**: Kind determines event lifecycle and replaceability.
|
||||
|
||||
## Creating Events
|
||||
|
||||
### EventTemplate Pattern
|
||||
|
||||
```kotlin
|
||||
fun eventTemplate(
|
||||
kind: Kind,
|
||||
content: String,
|
||||
tags: TagArray = emptyArray()
|
||||
): EventTemplate
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
val template = eventTemplate(
|
||||
kind = 1, // Text note
|
||||
content = "Hello Nostr!",
|
||||
tags = tagArray {
|
||||
add(arrayOf("subject", "Greeting"))
|
||||
}
|
||||
)
|
||||
|
||||
// Sign with a signer
|
||||
val signedEvent = signer.sign(template)
|
||||
```
|
||||
|
||||
**Why templates?** Separates event data from signing. Templates can be signed by different signers (local keys, remote signers, hardware wallets).
|
||||
|
||||
### TagArrayBuilder DSL
|
||||
|
||||
```kotlin
|
||||
fun <T : Event> tagArray(
|
||||
initializer: TagArrayBuilder<T>.() -> Unit
|
||||
): TagArray
|
||||
```
|
||||
|
||||
**Methods**:
|
||||
- `add(tag)` - Append tag
|
||||
- `addFirst(tag)` - Prepend tag (for ordering)
|
||||
- `addUnique(tag)` - Replace all tags with this name
|
||||
- `remove(tagName)` - Remove by name
|
||||
- `addAll(tags)` - Bulk add
|
||||
|
||||
**Example**:
|
||||
```kotlin
|
||||
val tags = tagArray<TextNoteEvent> {
|
||||
add(arrayOf("e", replyToEventId, "", "reply"))
|
||||
add(arrayOf("p", authorPubkey))
|
||||
addUnique(arrayOf("subject", "Re: Hello"))
|
||||
add(arrayOf("content-warning", "spoilers"))
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Fluent DSL for building tag arrays with validation and deduplication.
|
||||
|
||||
## Common Event Types
|
||||
|
||||
### TextNoteEvent (kind 1)
|
||||
|
||||
```kotlin
|
||||
class TextNoteEvent : BaseThreadedEvent
|
||||
```
|
||||
|
||||
**Creating**:
|
||||
```kotlin
|
||||
val note = eventTemplate(
|
||||
kind = 1,
|
||||
content = "Hello world!",
|
||||
tags = tagArray {
|
||||
add(arrayOf("subject", "First post"))
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Parsing**:
|
||||
```kotlin
|
||||
val event: TextNoteEvent = ...
|
||||
val subject = event.subject() // Extension from nip14Subject
|
||||
val mentions = event.mentions() // List of p-tags
|
||||
val quotedEvents = event.quotes() // List of q-tags
|
||||
```
|
||||
|
||||
### ReactionEvent (kind 7)
|
||||
|
||||
```kotlin
|
||||
fun createReaction(
|
||||
targetEvent: Event,
|
||||
emoji: String = "+"
|
||||
): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 7,
|
||||
content = emoji,
|
||||
tags = tagArray {
|
||||
add(arrayOf("e", targetEvent.id))
|
||||
add(arrayOf("p", targetEvent.pubKey))
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### MetadataEvent (kind 0)
|
||||
|
||||
```kotlin
|
||||
data class UserMetadata(
|
||||
val name: String?,
|
||||
val displayName: String?,
|
||||
val picture: String?,
|
||||
val banner: String?,
|
||||
val about: String?,
|
||||
// ... more fields
|
||||
)
|
||||
|
||||
fun createMetadata(metadata: UserMetadata): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 0,
|
||||
content = metadata.toJson() // Serialize to JSON
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Addressable Events (kinds 30000-40000)
|
||||
|
||||
```kotlin
|
||||
fun createArticle(
|
||||
slug: String,
|
||||
title: String,
|
||||
content: String
|
||||
): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 30023,
|
||||
content = content,
|
||||
tags = tagArray {
|
||||
addUnique(arrayOf("d", slug)) // Unique identifier
|
||||
add(arrayOf("title", title))
|
||||
add(arrayOf("published_at", "${TimeUtils.now()}"))
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Key**: `d-tag` makes it addressable. Events with same kind + pubkey + d-tag replace each other.
|
||||
|
||||
## Tag Patterns
|
||||
|
||||
Tags are `Array<String>` with pattern `[name, value, ...optionalParams]`.
|
||||
|
||||
### Core Tags
|
||||
|
||||
**e-tag** (event reference):
|
||||
```kotlin
|
||||
add(arrayOf("e", eventId, relayHint, marker))
|
||||
// marker: "reply", "root", "mention"
|
||||
```
|
||||
|
||||
**p-tag** (pubkey reference):
|
||||
```kotlin
|
||||
add(arrayOf("p", pubkey, relayHint))
|
||||
```
|
||||
|
||||
**a-tag** (addressable event):
|
||||
```kotlin
|
||||
add(arrayOf("a", "$kind:$pubkey:$dtag", relayHint))
|
||||
```
|
||||
|
||||
**d-tag** (identifier for addressable events):
|
||||
```kotlin
|
||||
addUnique(arrayOf("d", "unique-slug"))
|
||||
```
|
||||
|
||||
### Tag Extensions
|
||||
|
||||
```kotlin
|
||||
// Find tags
|
||||
event.tags.tagValue("subject") // First subject tag value
|
||||
event.tags.allTags("p") // All p-tags
|
||||
event.tags.tagValues("e") // All e-tag values
|
||||
|
||||
// Parse structured tags
|
||||
event.tags.mapNotNull(ETag::parse) // Parse as ETag objects
|
||||
```
|
||||
|
||||
For comprehensive tag patterns, see `references/tag-patterns.md`.
|
||||
|
||||
## Threading (NIP-10)
|
||||
|
||||
```kotlin
|
||||
fun createReply(
|
||||
original: TextNoteEvent,
|
||||
content: String
|
||||
): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 1,
|
||||
content = content,
|
||||
tags = tagArray {
|
||||
// Reply marker
|
||||
add(arrayOf("e", original.id, "", "reply"))
|
||||
|
||||
// Root marker (original's root, or original itself)
|
||||
original.rootEvent()?.let {
|
||||
add(arrayOf("e", it.id, "", "root"))
|
||||
} ?: add(arrayOf("e", original.id, "", "root"))
|
||||
|
||||
// Tag author
|
||||
add(arrayOf("p", original.pubKey))
|
||||
|
||||
// Tag all mentioned users
|
||||
original.mentions().forEach {
|
||||
add(arrayOf("p", it))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: `reply` and `root` markers establish thread hierarchy.
|
||||
|
||||
## Cryptography
|
||||
|
||||
### Signing (secp256k1)
|
||||
|
||||
```kotlin
|
||||
interface ISigner {
|
||||
suspend fun sign(template: EventTemplate): Event
|
||||
}
|
||||
|
||||
// Local key signing
|
||||
class LocalSigner(private val privateKey: ByteArray) : ISigner {
|
||||
override suspend fun sign(template: EventTemplate): Event {
|
||||
val id = template.generateId()
|
||||
val sig = Secp256k1.sign(id, privateKey)
|
||||
return Event(id, pubKey, createdAt, kind, tags, content, sig)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Signers abstract key management. Can be local, remote (NIP-46), or hardware.
|
||||
|
||||
### Encryption (NIP-44)
|
||||
|
||||
```kotlin
|
||||
// Modern encryption (ChaCha20-Poly1305)
|
||||
object Nip44v2 {
|
||||
fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
}
|
||||
|
||||
// Usage
|
||||
val encrypted = Nip44v2.encrypt(
|
||||
plaintext = "Secret message",
|
||||
privateKey = myPrivateKey,
|
||||
pubKey = recipientPubKey
|
||||
)
|
||||
|
||||
val decrypted = Nip44v2.decrypt(
|
||||
ciphertext = encrypted,
|
||||
privateKey = myPrivateKey,
|
||||
pubKey = senderPubKey
|
||||
)
|
||||
```
|
||||
|
||||
**Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD.
|
||||
|
||||
### NIP-04 (Deprecated)
|
||||
|
||||
```kotlin
|
||||
// Legacy encryption (NIP-04, deprecated for NIP-44)
|
||||
object Nip04 {
|
||||
fun encrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
fun decrypt(msg: String, privateKey: ByteArray, pubKey: HexKey): String
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues.
|
||||
|
||||
## Bech32 Encoding (NIP-19)
|
||||
|
||||
```kotlin
|
||||
object Nip19 {
|
||||
// Encode
|
||||
fun npubEncode(pubkey: HexKey): String // npub1...
|
||||
fun nsecEncode(privateKey: ByteArray): String // nsec1...
|
||||
fun noteEncode(eventId: HexKey): String // note1...
|
||||
fun neventEncode(eventId: HexKey, relays: List<String> = emptyList()): String
|
||||
fun nprofileEncode(pubkey: HexKey, relays: List<String> = emptyList()): String
|
||||
fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List<String> = emptyList()): String
|
||||
|
||||
// Decode
|
||||
fun decode(bech32: String): Nip19Result
|
||||
}
|
||||
|
||||
sealed class Nip19Result {
|
||||
data class NPub(val hex: HexKey) : Nip19Result()
|
||||
data class NSec(val hex: HexKey) : Nip19Result()
|
||||
data class Note(val hex: HexKey) : Nip19Result()
|
||||
data class NEvent(val hex: HexKey, val relays: List<String>) : Nip19Result()
|
||||
data class NProfile(val hex: HexKey, val relays: List<String>) : Nip19Result()
|
||||
data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List<String>) : Nip19Result()
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
// Encode
|
||||
val npub = Nip19.npubEncode(pubkeyHex)
|
||||
// Output: "npub1..."
|
||||
|
||||
// Decode
|
||||
when (val result = Nip19.decode(npub)) {
|
||||
is Nip19Result.NPub -> println("Pubkey: ${result.hex}")
|
||||
is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}")
|
||||
else -> println("Other type")
|
||||
}
|
||||
```
|
||||
|
||||
## Event Validation
|
||||
|
||||
```kotlin
|
||||
fun Event.verify(): Boolean {
|
||||
// 1. Verify ID matches content hash
|
||||
val computedId = generateId()
|
||||
if (id != computedId) return false
|
||||
|
||||
// 2. Verify signature
|
||||
return Secp256k1.verify(id, sig, pubKey)
|
||||
}
|
||||
|
||||
fun Event.generateId(): HexKey {
|
||||
val serialized = serializeForId() // JSON array format
|
||||
return sha256(serialized)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Always verify events from untrusted sources (relays).
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Publishing an Event
|
||||
|
||||
```kotlin
|
||||
suspend fun publishNote(content: String, signer: ISigner, relays: List<String>) {
|
||||
// 1. Create template
|
||||
val template = eventTemplate(kind = 1, content = content)
|
||||
|
||||
// 2. Sign
|
||||
val event = signer.sign(template)
|
||||
|
||||
// 3. Verify (optional but recommended)
|
||||
require(event.verify()) { "Signature verification failed" }
|
||||
|
||||
// 4. Publish to relays
|
||||
relays.forEach { relay ->
|
||||
relayClient.send(relay, event)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Querying Events
|
||||
|
||||
```kotlin
|
||||
// Subscription filter
|
||||
data class Filter(
|
||||
val ids: List<HexKey>? = null,
|
||||
val authors: List<HexKey>? = null,
|
||||
val kinds: List<Kind>? = null,
|
||||
val since: Long? = null,
|
||||
val until: Long? = null,
|
||||
val limit: Int? = null,
|
||||
val tags: Map<String, List<String>>? = null // e.g., {"#e": [eventId], "#p": [pubkey]}
|
||||
)
|
||||
|
||||
// Usage
|
||||
val filter = Filter(
|
||||
authors = listOf(userPubkey),
|
||||
kinds = listOf(1), // Text notes only
|
||||
limit = 50
|
||||
)
|
||||
|
||||
relayClient.subscribe(relay, filter) { event ->
|
||||
// Handle incoming events
|
||||
}
|
||||
```
|
||||
|
||||
### Creating a Zap (NIP-57)
|
||||
|
||||
```kotlin
|
||||
fun createZapRequest(
|
||||
targetEvent: Event,
|
||||
amountSats: Long,
|
||||
comment: String = ""
|
||||
): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 9734, // Zap request
|
||||
content = comment,
|
||||
tags = tagArray {
|
||||
add(arrayOf("e", targetEvent.id))
|
||||
add(arrayOf("p", targetEvent.pubKey))
|
||||
add(arrayOf("amount", "${amountSats * 1000}")) // millisats
|
||||
add(arrayOf("relays", "wss://relay1.com", "wss://relay2.com"))
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Gift-Wrapped DMs (NIP-17)
|
||||
|
||||
```kotlin
|
||||
fun createGiftWrappedDM(
|
||||
recipientPubkey: HexKey,
|
||||
message: String,
|
||||
signer: ISigner
|
||||
): Event {
|
||||
// 1. Create sealed gossip (kind 14)
|
||||
val sealedGossip = createSealedGossip(message, recipientPubkey, signer)
|
||||
|
||||
// 2. Wrap in gift wrap (kind 1059)
|
||||
return createGiftWrap(sealedGossip, recipientPubkey, signer)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Double encryption + random ephemeral keys for metadata protection.
|
||||
|
||||
## Finding NIPs
|
||||
|
||||
Use the bundled script:
|
||||
|
||||
```bash
|
||||
# Find by NIP number
|
||||
scripts/nip-lookup.sh 44
|
||||
|
||||
# Search by term
|
||||
scripts/nip-lookup.sh encryption
|
||||
scripts/nip-lookup.sh "gift wrap"
|
||||
```
|
||||
|
||||
Or see `references/nip-catalog.md` for complete catalog.
|
||||
|
||||
## Bundled Resources
|
||||
|
||||
- **references/nip-catalog.md** - All 57 NIPs with package locations and key files
|
||||
- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types
|
||||
- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
|
||||
- **scripts/nip-lookup.sh** - Find NIP implementations by number or search term
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Pattern | Location |
|
||||
|------|---------|----------|
|
||||
| Create event | `eventTemplate(kind, content, tags)` | nip01Core/signers/ |
|
||||
| Build tags | `tagArray { add(...) }` | nip01Core/core/ |
|
||||
| Sign event | `signer.sign(template)` | nip01Core/signers/ |
|
||||
| Verify signature | `event.verify()` | nip01Core/core/ |
|
||||
| Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ |
|
||||
| Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ |
|
||||
| Find NIP | `scripts/nip-lookup.sh <number>` | - |
|
||||
|
||||
## Common Event Kinds
|
||||
|
||||
| Kind | Type | NIP | Package |
|
||||
|------|------|-----|---------|
|
||||
| 0 | Metadata | 01 | nip01Core/ |
|
||||
| 1 | Text note | 01, 10 | nip10Notes/ |
|
||||
| 3 | Contact list | 02 | nip02FollowList/ |
|
||||
| 5 | Deletion | 09 | nip09Deletions/ |
|
||||
| 7 | Reaction | 25 | nip25Reactions/ |
|
||||
| 1059 | Gift wrap | 59 | nip59Giftwrap/ |
|
||||
| 9734 | Zap request | 57 | nip57Zaps/ |
|
||||
| 9735 | Zap receipt | 57 | nip57Zaps/ |
|
||||
| 10002 | Relay list | 65 | nip65RelayList/ |
|
||||
| 30023 | Long-form content | 23 | nip23LongContent/ |
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **nostr-protocol** - NIP specifications and protocol details
|
||||
- **kotlin-expert** - Kotlin patterns (@Immutable, sealed classes, DSLs)
|
||||
- **kotlin-coroutines** - Async patterns for relay communication
|
||||
- **kotlin-multiplatform** - KMP patterns, expect/actual in Quartz
|
||||
@@ -0,0 +1,293 @@
|
||||
# Event Hierarchy & Structure
|
||||
|
||||
## Core Hierarchy
|
||||
|
||||
```
|
||||
IEvent (empty interface)
|
||||
└── Event (@Immutable base class)
|
||||
├── BaseAddressableEvent (replaceable + addressable, has d-tag)
|
||||
│ ├── BaseReplaceableEvent (kinds 10000-20000, FIXED_D_TAG = "")
|
||||
│ └── [Specific addressable events - 30000-40000]
|
||||
└── [Specific event implementations - all other kinds]
|
||||
```
|
||||
|
||||
## Event Base Class
|
||||
|
||||
**Location**: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Event.kt`
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
open class Event(
|
||||
val id: HexKey, // SHA-256 hash of serialized event
|
||||
val pubKey: HexKey, // Author's public key (32 bytes hex)
|
||||
val createdAt: Long, // Unix timestamp
|
||||
val kind: Kind, // Event kind (Int typealias)
|
||||
val tags: TagArray, // Array of tag arrays
|
||||
val content: String, // Event content
|
||||
val sig: HexKey, // schnorr signature (64 bytes hex)
|
||||
) : IEvent, OptimizedSerializable
|
||||
```
|
||||
|
||||
## Kind Classification
|
||||
|
||||
```kotlin
|
||||
typealias Kind = Int
|
||||
|
||||
fun Kind.isEphemeral() = this in 20000..29999
|
||||
fun Kind.isReplaceable() = this == 0 || this == 3 || this in 10000..19999
|
||||
fun Kind.isAddressable() = this in 30000..39999
|
||||
fun Kind.isRegular() = this in 1000..9999
|
||||
```
|
||||
|
||||
## Common Event Types
|
||||
|
||||
### Text Note (kind 1)
|
||||
```kotlin
|
||||
class TextNoteEvent(...) : BaseThreadedEvent(...),
|
||||
EventHintProvider, AddressHintProvider, PubKeyHintProvider, SearchableEvent
|
||||
|
||||
// Threading support via markers: reply, root, mention
|
||||
fun replyTo(): List<Note> // Direct reply targets
|
||||
fun root(): Note? // Root of thread
|
||||
```
|
||||
|
||||
### Metadata (kind 0)
|
||||
```kotlin
|
||||
class MetadataEvent(...) : BaseAddressableEvent(...)
|
||||
|
||||
// Replaceable: newest version overwrites old
|
||||
// d-tag automatically set to "" for kind 0
|
||||
fun name(): String?
|
||||
fun displayName(): String?
|
||||
fun picture(): String?
|
||||
fun about(): String?
|
||||
fun lnAddress(): String?
|
||||
```
|
||||
|
||||
### Reaction (kind 7)
|
||||
```kotlin
|
||||
class ReactionEvent(...) : Event(...)
|
||||
|
||||
companion object {
|
||||
const val LIKE = "+"
|
||||
const val DISLIKE = "-"
|
||||
|
||||
fun like(reactedTo: EventHintBundle<Event>, ...)
|
||||
fun dislike(reactedTo: EventHintBundle<Event>, ...)
|
||||
}
|
||||
```
|
||||
|
||||
### Zap Request/Receipt (kinds 9734, 9735)
|
||||
```kotlin
|
||||
class LnZapRequestEvent(...) : Event(...)
|
||||
// Created by client, sent to Lightning Address
|
||||
|
||||
class LnZapEvent(...) : Event(...)
|
||||
// Receipt from LSP, contains bolt11 + embedded zap request
|
||||
val zapRequest: LnZapRequestEvent? by lazy { containedPost() }
|
||||
val amount: BigDecimal? by lazy { /* parse from bolt11 */ }
|
||||
```
|
||||
|
||||
### Long-Form Content (kind 30023)
|
||||
```kotlin
|
||||
class LongTextNoteEvent(...) : BaseAddressableEvent(...)
|
||||
// Blog posts, articles
|
||||
// Addressable via kind:pubkey:d-tag
|
||||
```
|
||||
|
||||
### Lists (kinds 10000-30004)
|
||||
```kotlin
|
||||
sealed class PeopleListEvent : BaseAddressableEvent {
|
||||
object MuteList : PeopleListEvent(10000)
|
||||
object PinList : PeopleListEvent(10001)
|
||||
object BookmarkList : PeopleListEvent(10003)
|
||||
// ... 18 list types total
|
||||
}
|
||||
```
|
||||
|
||||
## Event Interfaces
|
||||
|
||||
### Hint Providers
|
||||
Events can implement interfaces to optimize relay queries:
|
||||
|
||||
```kotlin
|
||||
interface EventHintProvider {
|
||||
fun taggedEventIds(): Set<HexKey>
|
||||
fun taggedEventRelays(): Map<HexKey, Set<NormalizedRelayUrl>>
|
||||
}
|
||||
|
||||
interface PubKeyHintProvider {
|
||||
fun taggedPubKeys(): Set<HexKey>
|
||||
fun taggedPubKeyRelays(): Map<HexKey, Set<NormalizedRelayUrl>>
|
||||
}
|
||||
|
||||
interface AddressHintProvider {
|
||||
fun taggedAddresses(): Set<Address>
|
||||
fun taggedAddressRelays(): Map<Address, Set<NormalizedRelayUrl>>
|
||||
}
|
||||
|
||||
interface SearchableEvent {
|
||||
fun subject(): String?
|
||||
fun isContentEncoded(): Boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Event Building Pattern
|
||||
|
||||
### DSL Builder
|
||||
```kotlin
|
||||
TextNoteEvent.build(
|
||||
note = "Hello Nostr",
|
||||
replyingTo = eventBundle,
|
||||
createdAt = TimeUtils.now()
|
||||
) {
|
||||
pTag(pubKey, relayHint) // Tag person
|
||||
eTag(eventId, relayHint, "reply") // Tag event with marker
|
||||
hashtag("nostr") // Add hashtag
|
||||
alt("A short note") // Alt text
|
||||
}
|
||||
```
|
||||
|
||||
### Event Template (Low-level)
|
||||
```kotlin
|
||||
suspend fun eventTemplate(
|
||||
kind: Kind,
|
||||
content: String,
|
||||
createdAt: Long,
|
||||
initializer: TagArrayBuilder.() -> Unit
|
||||
): EventTemplate {
|
||||
val tags = TagArrayBuilder().apply(initializer).build()
|
||||
return EventTemplate(kind, tags, content, createdAt)
|
||||
}
|
||||
|
||||
// Sign with signer
|
||||
val template = eventTemplate(1, "Hello", now()) { pTag(pubkey) }
|
||||
val signedEvent = signer.sign(template)
|
||||
```
|
||||
|
||||
## Addressable vs Regular Events
|
||||
|
||||
| Feature | Regular Event | Addressable Event |
|
||||
|---------|---------------|-------------------|
|
||||
| **Identifier** | Event ID (SHA-256 hash) | Address (kind:pubkey:d-tag) |
|
||||
| **Replaceability** | Immutable | Newest replaces old |
|
||||
| **d-tag** | Optional | Required |
|
||||
| **Lookup** | By event ID | By address |
|
||||
| **Example** | Text note (kind 1) | Metadata (kind 0), Long-form (kind 30023) |
|
||||
|
||||
```kotlin
|
||||
// Regular event address
|
||||
note = LocalCache.getNoteIfExists(eventId)
|
||||
|
||||
// Addressable event address
|
||||
address = Address(kind = 30023, pubkey = authorHex, dTag = "my-article")
|
||||
note = LocalCache.getAddressableNoteIfExists(address)
|
||||
```
|
||||
|
||||
## Event Validation
|
||||
|
||||
```kotlin
|
||||
// Verify event ID matches computed hash
|
||||
fun Event.verifyId(): Boolean =
|
||||
EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content)
|
||||
|
||||
// Verify signature
|
||||
fun Event.verifySignature(): Boolean =
|
||||
Nip01.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
|
||||
|
||||
// Complete verification
|
||||
fun Event.checkSignature() {
|
||||
if (!verifyId()) throw Exception("ID mismatch")
|
||||
if (!verifySignature()) throw Exception("Bad signature!")
|
||||
}
|
||||
```
|
||||
|
||||
## Event Serialization
|
||||
|
||||
```kotlin
|
||||
// To JSON (for transmission/signing)
|
||||
fun Event.toJson(): String = OptimizedJsonMapper.toJson(this)
|
||||
|
||||
// From JSON
|
||||
fun Event.fromJson(json: String): Event = OptimizedJsonMapper.fromJson(json)
|
||||
|
||||
// Event ID generation (SHA-256 of canonical JSON)
|
||||
fun EventHasher.hashId(
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Kind,
|
||||
tags: TagArray,
|
||||
content: String
|
||||
): HexKey {
|
||||
val serialized = """[0,"$pubKey",$createdAt,$kind,${tags.toJson()},"$content"]"""
|
||||
return sha256(serialized.encodeToByteArray()).toHexKey()
|
||||
}
|
||||
```
|
||||
|
||||
## Event Lifecycle in LocalCache
|
||||
|
||||
```
|
||||
Event received from relay
|
||||
↓
|
||||
LocalCache.consume(event, relay, wasVerified)
|
||||
↓
|
||||
getOrCreateNote(event.id) or getOrCreateAddressableNote(address)
|
||||
↓
|
||||
justVerify(event) → checkSignature()
|
||||
↓
|
||||
note.loadEvent(event, author, replyTo)
|
||||
↓
|
||||
Update indices (replies, reactions, boosts)
|
||||
↓
|
||||
refreshNewNoteObservers(note) → emit to SharedFlow
|
||||
↓
|
||||
UI updates
|
||||
```
|
||||
|
||||
## Common Event Patterns
|
||||
|
||||
### Reply Threading
|
||||
```kotlin
|
||||
// Root event (top of thread)
|
||||
val rootEvent = TextNoteEvent.build("Thread root") { }
|
||||
|
||||
// Reply to root
|
||||
val reply1 = TextNoteEvent.build("First reply", replyingTo = rootEvent) {
|
||||
// Automatically adds:
|
||||
// ["e", <root_id>, <relay>, "root"]
|
||||
// ["e", <root_id>, <relay>, "reply"]
|
||||
}
|
||||
|
||||
// Reply to reply (nested)
|
||||
val reply2 = TextNoteEvent.build("Nested reply", replyingTo = reply1) {
|
||||
// Automatically adds:
|
||||
// ["e", <root_id>, <relay>, "root"]
|
||||
// ["e", <reply1_id>, <relay>, "reply"]
|
||||
}
|
||||
```
|
||||
|
||||
### Replaceable Events
|
||||
```kotlin
|
||||
// Metadata update (kind 0) - newest wins
|
||||
val metadata1 = MetadataEvent.createNew(name = "Alice", picture = "url1")
|
||||
Thread.sleep(1000)
|
||||
val metadata2 = MetadataEvent.createNew(name = "Alice Updated", picture = "url2")
|
||||
|
||||
// LocalCache keeps only metadata2 (higher createdAt)
|
||||
```
|
||||
|
||||
### Event Deletion
|
||||
```kotlin
|
||||
// Delete events
|
||||
val deletion = DeletionEvent.create(
|
||||
deleteEvents = listOf(eventId1, eventId2),
|
||||
reason = "Spam",
|
||||
signer = signer
|
||||
)
|
||||
|
||||
// LocalCache marks events as deleted, but doesn't remove (for verification)
|
||||
```
|
||||
|
||||
## 63+ Event Classes
|
||||
|
||||
Full list at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip*/` - one class per event type across 60+ NIP implementations.
|
||||
@@ -0,0 +1,179 @@
|
||||
# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz
|
||||
|
||||
## Standard NIPs by Category
|
||||
|
||||
### Core/Basic Protocol
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 01 | `nip01Core/` | Event.kt, Kind.kt, Tag.kt | Core protocol, event structure, kinds, tags |
|
||||
| 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) |
|
||||
| 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps |
|
||||
| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) |
|
||||
| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification |
|
||||
| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation |
|
||||
| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) |
|
||||
| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata |
|
||||
| 13 | `nip13Pow/` | ProofOfWork.kt | Proof of work |
|
||||
| 14 | `nip14Subject/` | Subject tags | Subject tags for text notes |
|
||||
| 17 | `nip17Dm/` | GiftWrapEvent.kt, SealedGossipEvent.kt | Private DMs (replacem
|
||||
|
||||
ent for NIP-04) |
|
||||
| 21 | `nip21UriScheme/` | URI scheme (`nostr:`) | URI scheme parsing |
|
||||
| 42 | `nip42RelayAuth/` | RelayAuthEvent.kt | Relay authentication (kind 22242) |
|
||||
| 44 | `nip44Encryption/` | Nip44.kt, Nip44v2.kt | Modern encryption (ChaCha20) |
|
||||
| 49 | `nip49PrivKeyEnc/` | NIP-49Ncryptsec.kt | Private key encryption format |
|
||||
|
||||
### Content Types
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 10 | `nip10Notes/` | TextNoteEvent.kt | Text notes with threading (kind 1) |
|
||||
| 18 | `nip18Reposts/` | RepostEvent.kt, GenericRepostEvent.kt | Reposts (kind 6, 16) |
|
||||
| 22 | `nip22Comments/` | CommentEvent.kt | Comments (kind 1111) |
|
||||
| 23 | `nip23LongContent/` | LongTextNoteEvent.kt | Long-form content (kind 30023) |
|
||||
| 25 | `nip25Reactions/` | ReactionEvent.kt | Reactions (kind 7) |
|
||||
| 31 | `nip31Alts/` | Alt tags | Alt description tags |
|
||||
| 36 | `nip36SensitiveContent/` | Content warnings | Content warning tags |
|
||||
| 37 | `nip37Drafts/` | DraftEvent.kt | Drafts (kind 31234) |
|
||||
| 50 | `nip50Search/` | Search filters | Full-text search |
|
||||
|
||||
### Encoding & Standards
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 19 | `nip19Bech32/` | Nip19.kt | Bech32 encoding (npub, nsec, note, nevent, nprofile, naddr) |
|
||||
| 40 | `nip40Expiration/` | Expiration tags | Event expiration |
|
||||
| 48 | `nip48ProxyTags/` | Proxy tags | Proxy tags for delegation |
|
||||
| 62 | `nip62RequestToVanish/` | RequestToVanishEvent.kt | Request to vanish (kind 12) |
|
||||
| 98 | `nip98HttpAuth/` | HTTP authorization | HTTP auth header |
|
||||
|
||||
### Lists & Management
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 51 | `nip51Lists/` | 18 list types | Named lists (mute, bookmarks, pins, communities, etc.) (kinds 10000-30004) |
|
||||
| 65 | `nip65RelayList/` | AdvertisedRelayListEvent.kt | Relay lists (kind 10002) |
|
||||
|
||||
### Social & Identity
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 39 | `nip39ExtIdentities/` | External identities | External identity claims |
|
||||
| 46 | `nip46RemoteSigner/` | NostrConnectEvent.kt | Remote signer protocol (bunker) |
|
||||
| 47 | `nip47WalletConnect/` | Nostr Wallet Connect | Wallet connection protocol |
|
||||
| 56 | `nip56Reports/` | ReportEvent.kt | Reports (kind 1984) |
|
||||
| 57 | `nip57Zaps/` | LnZapEvent.kt, LnZapRequestEvent.kt | Lightning zaps (kinds 9734, 9735) |
|
||||
| 58 | `nip58Badges/` | Badge events | Badge definitions & awards (kinds 30009, 8) |
|
||||
| 59 | `nip59Giftwrap/` | GiftWrapEvent.kt | Gift-wrapped events for privacy |
|
||||
| 75 | `nip75ZapGoals/` | ZapGoalEvent.kt | Zap goals (kind 9041) |
|
||||
|
||||
### Specialized Content
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 28 | `nip28PublicChat/` | ChannelCreateEvent.kt, ChannelMessageEvent.kt | Public chat channels (kinds 40-44) |
|
||||
| 30 | `nip30CustomEmoji/` | EmojiUrl.kt | Custom emoji |
|
||||
| 34 | `nip34Git/` | Git patch/issue events | Git repository tracking (kinds 30617, 30618, 1617, 1621, 1622, 1630, 1633) |
|
||||
| 35 | `nip35Torrents/` | Torrent events | Torrent tracking |
|
||||
| 52 | `nip52Calendar/` | Calendar events | Calendar time-based/date-based (kinds 31922-31925) |
|
||||
| 53 | `nip53LiveActivities/` | LiveActivitiesEvent.kt | Live events/streaming (kind 30311) |
|
||||
| 54 | `nip54Wiki/` | WikiNoteEvent.kt | Wiki pages (kind 30818) |
|
||||
| 68 | `nip68Picture/` | Picture metadata | Picture metadata |
|
||||
| 71 | `nip71Video/` | 7 video event types | Video events (kinds 34235, 35235, 1234, 1235) |
|
||||
| 72 | `nip72ModCommunities/` | Community events | Moderated communities (kinds 34550, 34551, 9041) |
|
||||
| 84 | `nip84Highlights/` | HighlightEvent.kt | Highlights (kind 9802) |
|
||||
| 89 | `nip89AppHandlers/` | AppDefinitionEvent.kt | App recommendations (kinds 31990, 31989) |
|
||||
| 90 | `nip90Dvms/` | DVM job events | Data Vending Machines (DVMs) (kinds 5000-7000) |
|
||||
| 92 | `nip92IMeta/` | IMeta tags | Image metadata tags |
|
||||
| 94 | `nip94FileMetadata/` | FileHeaderEvent.kt, FileStorageEvent.kt | File metadata (kind 1063) |
|
||||
| 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage |
|
||||
| 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) |
|
||||
| A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events |
|
||||
| B7 | `nipB7Blossom/` | Blossom server URLs | Blossom file storage |
|
||||
|
||||
### Web/Storage/Other
|
||||
| NIP | Directory | Key Files | Description |
|
||||
|-----|-----------|-----------|-------------|
|
||||
| 38 | `nip38UserStatus/` | StatusEvent.kt | User status (kind 30315) |
|
||||
| 60 | `nip60Payment/` | Wallet events | Wallet info (kind 13194) |
|
||||
| 61 | `nip61PaymentRequest/` | Nut zaps | Cashu payment requests |
|
||||
| 64 | `nip64Chess/` | Chess moves | Chess move events |
|
||||
| 66 | `nip66Monitoring/` | Relay monitor events | Relay monitoring |
|
||||
| 67 | `nip67Invoices/` | Invoice tags | Lightning invoice tags |
|
||||
| 69 | `nip69Offers/` | BOLT-12 offers | BOLT-12 offer tags |
|
||||
| 70 | `nip70ProtectedEvts/` | Protected events | Protected event types |
|
||||
| 73 | `nip73ExternalIds/` | External content IDs | External content identifiers |
|
||||
| 78 | `nip78AppData/` | AppDataEvent.kt | Application data (kind 30078) |
|
||||
| 79 | `nip79Labels/` | Label events | Labeling (kinds 1985, 1986) |
|
||||
| 80-88 | Various | Various protocols | Relationship, preferences, polls, surveys, social graphs, etc. |
|
||||
| 91 | `nip91Feed/` | Feed display events | Feed definitions |
|
||||
| 93 | `nip93Gallery/` | Gallery events | Gallery collections |
|
||||
| 95 | `nip95Storage/` | Storage event tags | Storage events |
|
||||
| 97 | `nip97Nests/` | Audio rooms | Audio room events |
|
||||
|
||||
## Experimental NIPs (18 packages)
|
||||
|
||||
Located at `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/`:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `audio/` | Audio content, track events |
|
||||
| `bounties/` | Bounty/funding events |
|
||||
| `decoupling/` | Decoupling setup |
|
||||
| `edits/` | Event edit tracking |
|
||||
| `ephemChat/` | Ephemeral encrypted chat |
|
||||
| `forks/` | Fork tracking |
|
||||
| `inlineMetadata/` | Inline metadata |
|
||||
| `interactiveStories/` | Interactive story events |
|
||||
| `limits/` | Limit enforcement |
|
||||
| `medical/` | Medical data |
|
||||
| `nip95/` | File storage support |
|
||||
| `nipA3/` | A3 protocol extension |
|
||||
| `nns/` | Nostr Name System |
|
||||
| `profileGallery/` | Profile gallery lists |
|
||||
| `publicMessages/` | Public message lists |
|
||||
| `relationshipStatus/` | Relationship status events |
|
||||
| `trustedAssertions/` | Trust/assertion events |
|
||||
| `zapPolls/` | Zap-based polling |
|
||||
|
||||
## Quick Lookup by Kind
|
||||
|
||||
| Kind | Event Type | NIP |
|
||||
|------|------------|-----|
|
||||
| 0 | Metadata | 01 |
|
||||
| 1 | Text Note | 01, 10 |
|
||||
| 3 | Follow List | 02 |
|
||||
| 4 | Encrypted DM (legacy) | 04 |
|
||||
| 5 | Deletion | 09 |
|
||||
| 6 | Repost | 18 |
|
||||
| 7 | Reaction | 25 |
|
||||
| 8 | Badge Award | 58 |
|
||||
| 16 | Generic Repost | 18 |
|
||||
| 40-44 | Channel Events | 28 |
|
||||
| 1063 | File Metadata | 94 |
|
||||
| 1111 | Comment | 22 |
|
||||
| 1617, 1621, 1622, 1630, 1633 | Git | 34 |
|
||||
| 1984 | Report | 56 |
|
||||
| 1985, 1986 | Label | 79 |
|
||||
| 9734 | Zap Request | 57 |
|
||||
| 9735 | Zap Receipt | 57 |
|
||||
| 9802 | Highlight | 84 |
|
||||
| 10000-20000 | Replaceable Lists | 51 |
|
||||
| 10002 | Relay List | 65 |
|
||||
| 13194 | Wallet Info | 60 |
|
||||
| 22242 | Relay Auth | 42 |
|
||||
| 23194, 23195 | NWC Payment | 47 |
|
||||
| 30000-40000 | Addressable Events | Various |
|
||||
| 30009 | Badge Definition | 58 |
|
||||
| 30023 | Long-Form Content | 23 |
|
||||
| 30078 | App Data | 78 |
|
||||
| 30311 | Live Event | 53 |
|
||||
| 30315 | User Status | 38 |
|
||||
| 30402 | Classifieds | 99 |
|
||||
| 30818 | Wiki | 54 |
|
||||
| 31234 | Draft | 37 |
|
||||
| 31922-31925 | Calendar | 52 |
|
||||
| 31989, 31990 | App Handlers | 89 |
|
||||
| 34235, 34550-34551 | Video/Communities | 71, 72 |
|
||||
| 5000-7000 | DVM Jobs | 90 |
|
||||
|
||||
## File Location Pattern
|
||||
|
||||
All NIPs located at: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip<NN><Name>/`
|
||||
|
||||
Example: NIP-57 → `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/`
|
||||
@@ -0,0 +1,336 @@
|
||||
# Tag Patterns in Quartz
|
||||
|
||||
Tags are the primary way events reference other events, users, and metadata in Nostr.
|
||||
|
||||
## Tag Structure
|
||||
|
||||
```kotlin
|
||||
typealias Tag = Array<String> // ["tag_name", "value", "optional_param", ...]
|
||||
typealias TagArray = Array<Tag>
|
||||
```
|
||||
|
||||
**Pattern**: `[name, value, ...optionalParams]`
|
||||
|
||||
## TagArrayBuilder DSL
|
||||
|
||||
```kotlin
|
||||
fun tagArray(initializer: TagArrayBuilder<T>.() -> Unit): TagArray
|
||||
```
|
||||
|
||||
**Methods**:
|
||||
- `add(tag)` - Append tag
|
||||
- `addFirst(tag)` - Prepend tag
|
||||
- `addUnique(tag)` - Replace all tags with this name
|
||||
- `remove(tagName)` - Remove all tags with name
|
||||
- `removeIf(predicate, toCompare)` - Conditional removal
|
||||
|
||||
**Example**:
|
||||
```kotlin
|
||||
val tags = tagArray<TextNoteEvent> {
|
||||
add(arrayOf("e", eventId, relayHint, "reply"))
|
||||
add(arrayOf("p", pubkey))
|
||||
addUnique(arrayOf("subject", "Hello"))
|
||||
}
|
||||
```
|
||||
|
||||
## Core Tag Types (NIP-01)
|
||||
|
||||
### e-tag (Event Reference)
|
||||
```kotlin
|
||||
// ["e", <event-id>, <relay-hint>, <marker>]
|
||||
arrayOf("e", eventId, "wss://relay.damus.io", "reply")
|
||||
```
|
||||
|
||||
**Markers** (NIP-10):
|
||||
- `root` - Root of thread
|
||||
- `reply` - Direct reply target
|
||||
- `mention` - Mentioned event (not reply)
|
||||
|
||||
**Extensions**:
|
||||
```kotlin
|
||||
// nip01Core/tags/
|
||||
fun TagArrayBuilder.eTag(eventId: HexKey, relay: String? = null, marker: String? = null)
|
||||
```
|
||||
|
||||
### p-tag (Pubkey Reference)
|
||||
```kotlin
|
||||
// ["p", <pubkey>, <relay-hint>]
|
||||
arrayOf("p", pubkey, "wss://relay.damus.io")
|
||||
```
|
||||
|
||||
**Usage**: Tag users, indicate recipients
|
||||
|
||||
**Extensions**:
|
||||
```kotlin
|
||||
fun TagArrayBuilder.pTag(pubkey: HexKey, relay: String? = null)
|
||||
```
|
||||
|
||||
### a-tag (Addressable Event Reference)
|
||||
```kotlin
|
||||
// ["a", <kind>:<pubkey>:<d-tag>, <relay-hint>]
|
||||
arrayOf("a", "30023:${authorPubkey}:${dtag}", "wss://relay.damus.io")
|
||||
```
|
||||
|
||||
**Usage**: Reference replaceable/addressable events (kinds 10000-20000, 30000-40000)
|
||||
|
||||
**Extensions**:
|
||||
```kotlin
|
||||
fun TagArrayBuilder.aTag(kind: Int, pubkey: HexKey, dTag: String, relay: String? = null)
|
||||
```
|
||||
|
||||
### d-tag (Identifier)
|
||||
```kotlin
|
||||
// ["d", <identifier>]
|
||||
arrayOf("d", "my-article-slug")
|
||||
```
|
||||
|
||||
**Usage**: Unique identifier for addressable events
|
||||
|
||||
## Common Tag Extensions
|
||||
|
||||
### Subject (NIP-14)
|
||||
```kotlin
|
||||
// nip14Subject/
|
||||
fun Event.subject(): String?
|
||||
fun TagArrayBuilder.subject(text: String)
|
||||
```
|
||||
|
||||
### Content Warning (NIP-36)
|
||||
```kotlin
|
||||
// nip36SensitiveContent/
|
||||
fun Event.contentWarning(): String?
|
||||
fun TagArrayBuilder.contentWarning(reason: String = "")
|
||||
```
|
||||
|
||||
### Expiration (NIP-40)
|
||||
```kotlin
|
||||
// nip40Expiration/
|
||||
fun Event.expiration(): Long?
|
||||
fun TagArrayBuilder.expiration(unixTimestamp: Long)
|
||||
```
|
||||
|
||||
### Alt Description (NIP-31)
|
||||
```kotlin
|
||||
// nip31Alts/
|
||||
fun Event.alt(): String?
|
||||
fun TagArrayBuilder.alt(description: String)
|
||||
```
|
||||
|
||||
## Specialized Tags
|
||||
|
||||
### Zap Tags (NIP-57)
|
||||
```kotlin
|
||||
// nip57Zaps/tags/
|
||||
class BoltTag(val bolt11: String, val preimage: String?)
|
||||
class DescriptionTag(val zapRequestJson: String)
|
||||
```
|
||||
|
||||
### Imeta Tags (NIP-92)
|
||||
```kotlin
|
||||
// nip92IMeta/
|
||||
class IMetaTag(val url: String, val metadata: Map<String, String>)
|
||||
|
||||
// Usage: Image metadata
|
||||
IMetaTag("https://example.com/image.jpg", mapOf(
|
||||
"m" to "image/jpeg",
|
||||
"dim" to "1920x1080",
|
||||
"blurhash" to "..."
|
||||
))
|
||||
```
|
||||
|
||||
### Relay Tags (NIP-65)
|
||||
```kotlin
|
||||
// nip65RelayList/
|
||||
class RelayTag(val url: String, val type: RelayType)
|
||||
enum class RelayType { READ, WRITE, BOTH }
|
||||
```
|
||||
|
||||
## Tag Query Patterns
|
||||
|
||||
### Finding Tags
|
||||
```kotlin
|
||||
// Extension functions on TagArray
|
||||
fun TagArray.firstTag(name: String): Tag?
|
||||
fun TagArray.allTags(name: String): List<Tag>
|
||||
fun TagArray.tagValue(name: String): String?
|
||||
fun TagArray.tagValues(name: String): List<String>
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```kotlin
|
||||
val event: TextNoteEvent = ...
|
||||
val subject = event.tags.tagValue("subject")
|
||||
val mentions = event.tags.allTags("p").mapNotNull { it.getOrNull(1) }
|
||||
```
|
||||
|
||||
### Parsing Tags
|
||||
```kotlin
|
||||
// Pattern: Companion object with parse methods
|
||||
object ETag {
|
||||
fun parse(tag: Tag): ETag? {
|
||||
if (tag.getOrNull(0) != "e") return null
|
||||
return ETag(
|
||||
eventId = tag.getOrNull(1) ?: return null,
|
||||
relay = tag.getOrNull(2),
|
||||
marker = tag.getOrNull(3)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
val eTags = event.tags.mapNotNull(ETag::parse)
|
||||
```
|
||||
|
||||
## Event Builder Pattern
|
||||
|
||||
Combining TagArrayBuilder with event creation:
|
||||
|
||||
```kotlin
|
||||
fun createTextNote(content: String, replyTo: Event?): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 1,
|
||||
content = content,
|
||||
tags = tagArray {
|
||||
replyTo?.let {
|
||||
eTag(it.id, marker = "reply")
|
||||
pTag(it.pubKey)
|
||||
it.rootEvent()?.let { root ->
|
||||
eTag(root.id, marker = "root")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Hint System
|
||||
|
||||
Tags can provide "hints" - optional relay URLs for fetching referenced content:
|
||||
|
||||
```kotlin
|
||||
// Event references
|
||||
["e", eventId, "wss://relay.example.com"] // relay hint
|
||||
|
||||
// Pubkey references
|
||||
["p", pubkey, "wss://relay.example.com"] // relay hint
|
||||
|
||||
// Addressable references
|
||||
["a", "30023:pubkey:dtag", "wss://relay.example.com"] // relay hint
|
||||
```
|
||||
|
||||
**Pattern**: Third parameter (index 2) is always the relay hint
|
||||
|
||||
## Tag Validation
|
||||
|
||||
```kotlin
|
||||
// Common validations
|
||||
fun validateETag(tag: Tag): Boolean {
|
||||
return tag.getOrNull(0) == "e" && tag.getOrNull(1)?.isValidHex() == true
|
||||
}
|
||||
|
||||
fun validatePTag(tag: Tag): Boolean {
|
||||
return tag.getOrNull(0) == "p" && tag.getOrNull(1)?.isValidHex() == true
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### Tag Indexing
|
||||
```kotlin
|
||||
// TagArrayBuilder keeps an index by tag name
|
||||
private val tagList = mutableMapOf<String, MutableList<Tag>>()
|
||||
|
||||
// Fast lookup by name
|
||||
fun remove(tagName: String) {
|
||||
tagList.remove(tagName)
|
||||
}
|
||||
```
|
||||
|
||||
### Lazy Parsing
|
||||
```kotlin
|
||||
// Don't parse all tags upfront
|
||||
class TextNoteEvent(...) {
|
||||
private val _mentions by lazy {
|
||||
tags.mapNotNull(PTag::parse)
|
||||
}
|
||||
|
||||
fun mentions() = _mentions
|
||||
}
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Creating a Reply
|
||||
```kotlin
|
||||
fun replyTo(original: TextNoteEvent, content: String): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 1,
|
||||
content = content,
|
||||
tags = tagArray {
|
||||
// Reply to this event
|
||||
eTag(original.id, marker = "reply")
|
||||
|
||||
// Copy root marker if exists, or mark original as root
|
||||
original.rootEvent()?.let {
|
||||
eTag(it.id, marker = "root")
|
||||
} ?: eTag(original.id, marker = "root")
|
||||
|
||||
// Tag author
|
||||
pTag(original.pubKey)
|
||||
|
||||
// Tag all mentioned users
|
||||
original.mentions().forEach { pTag(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Creating a Reaction
|
||||
```kotlin
|
||||
fun createReaction(targetEvent: Event, emoji: String): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 7,
|
||||
content = emoji,
|
||||
tags = tagArray {
|
||||
eTag(targetEvent.id)
|
||||
pTag(targetEvent.pubKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Creating an Addressable Event
|
||||
```kotlin
|
||||
fun createArticle(title: String, content: String, slug: String): EventTemplate {
|
||||
return eventTemplate(
|
||||
kind = 30023,
|
||||
content = content,
|
||||
tags = tagArray {
|
||||
addUnique(arrayOf("d", slug)) // Unique identifier
|
||||
add(arrayOf("title", title))
|
||||
add(arrayOf("published_at", "${TimeUtils.now()}"))
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Tag | NIP | Usage | Example |
|
||||
|-----|-----|-------|---------|
|
||||
| e | 01 | Event reference | `["e", eventId, relay, marker]` |
|
||||
| p | 01 | Pubkey reference | `["p", pubkey, relay]` |
|
||||
| a | 01 | Addressable event | `["a", "kind:pubkey:d"]` |
|
||||
| d | 01 | Identifier | `["d", "unique-id"]` |
|
||||
| subject | 14 | Subject line | `["subject", "Hello"]` |
|
||||
| content-warning | 36 | Content warning | `["content-warning", "nsfw"]` |
|
||||
| expiration | 40 | Expiration time | `["expiration", "1234567890"]` |
|
||||
| bolt11 | 57 | Lightning invoice | `["bolt11", "lnbc..."]` |
|
||||
| imeta | 92 | Media metadata | `["imeta", "url", "m", "image/jpeg"]` |
|
||||
| relay | 65 | User relays | `["relay", "wss://...", "read"]` |
|
||||
|
||||
## Resources
|
||||
|
||||
- Tag builders: `quartz/src/commonMain/.../nip01Core/tags/`
|
||||
- Tag extensions: Look for `TagArrayExt.kt`, `TagArrayBuilderExt.kt` in each NIP package
|
||||
- Event parsing: Each event class has tag parsing methods
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# Find NIP implementation files by NIP number or search term
|
||||
|
||||
set -e
|
||||
|
||||
QUARTZ_PATH="${QUARTZ_PATH:-./quartz/src/commonMain/kotlin/com/vitorpamplona/quartz}"
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <nip-number|search-term>"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 01 # Find NIP-01 files"
|
||||
echo " $0 44 # Find NIP-44 files"
|
||||
echo " $0 encryption # Search for 'encryption' in NIP packages"
|
||||
echo ""
|
||||
echo "Set QUARTZ_PATH to override default quartz location"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SEARCH_TERM="$1"
|
||||
|
||||
# Check if it's a number (NIP number)
|
||||
if [[ "$SEARCH_TERM" =~ ^[0-9]+$ ]]; then
|
||||
# Pad to 2 digits
|
||||
NIP_NUM=$(printf "%02d" "$SEARCH_TERM")
|
||||
echo "Searching for NIP-$NIP_NUM implementation..."
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Find directories matching nip##*
|
||||
find "$QUARTZ_PATH" -type d -name "nip${NIP_NUM}*" | while read -r dir; do
|
||||
echo "📁 $(basename "$dir")/"
|
||||
find "$dir" -name "*.kt" -type f | while read -r file; do
|
||||
rel_path="${file#$QUARTZ_PATH/}"
|
||||
echo " └─ $rel_path"
|
||||
done
|
||||
echo ""
|
||||
done
|
||||
else
|
||||
# Text search
|
||||
echo "Searching for '$SEARCH_TERM' in NIP packages..."
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
find "$QUARTZ_PATH" -type d -name "nip*" | while read -r dir; do
|
||||
if grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | head -1 > /dev/null; then
|
||||
echo "📁 $(basename "$dir")/"
|
||||
grep -r -l -i "$SEARCH_TERM" "$dir" --include="*.kt" 2>/dev/null | while read -r file; do
|
||||
rel_path="${file#$QUARTZ_PATH/}"
|
||||
matches=$(grep -c -i "$SEARCH_TERM" "$file" 2>/dev/null || echo "0")
|
||||
echo " └─ $rel_path ($matches matches)"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
Reference in New Issue
Block a user