initial skills

This commit is contained in:
nrobi144
2025-12-30 15:18:48 +02:00
parent 258c4e0111
commit f654af9d8a
23 changed files with 10898 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,615 @@
# Android Navigation Patterns
Complete navigation implementation patterns for Amethyst Android app using Navigation Compose with type safety.
## Type-Safe Routes (Navigation 2.8.0+)
### Route Definitions
```kotlin
// Routes.kt - All 40+ routes in Amethyst
@Serializable
sealed class Route {
// Bottom nav routes
@Serializable object Home : Route()
@Serializable object Messages : Route()
@Serializable object Video : Route()
@Serializable object Discover : Route()
@Serializable object Notification : Route()
// Content routes with parameters
@Serializable data class Profile(val pubkey: String) : Route()
@Serializable data class Note(val id: String) : Route()
@Serializable data class Channel(val id: String) : Route()
@Serializable data class Thread(
val id: String,
val replyTo: String? = null
) : Route()
// New content routes
@Serializable data class NewPost(
val message: String? = null,
val attachment: String? = null,
val replyTo: String? = null
) : Route()
// Settings
@Serializable object Settings : Route()
@Serializable object Security : Route()
@Serializable object Relays : Route()
// Search
@Serializable data class Search(val query: String = "") : Route()
// Media
@Serializable data class Image(val url: String) : Route()
@Serializable data class Video(val url: String) : Route()
}
```
## NavHost Configuration
### Basic Setup
```kotlin
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel,
drawerState: DrawerState
) {
val scope = rememberCoroutineScope()
val nav = remember {
Nav(navController, drawerState, scope)
}
NavHost(
navController = navController,
startDestination = Route.Home,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) },
popEnterTransition = { fadeIn(animationSpec = tween(200)) },
popExitTransition = { fadeOut(animationSpec = tween(200)) }
) {
// Define routes
composable<Route.Home> {
HomeScreen(accountViewModel, nav)
}
composable<Route.Profile> { backStackEntry ->
val profile = backStackEntry.toRoute<Route.Profile>()
ProfileScreen(
pubkey = profile.pubkey,
accountViewModel = accountViewModel,
nav = nav
)
}
composable<Route.Note> { backStackEntry ->
val note = backStackEntry.toRoute<Route.Note>()
NoteScreen(
noteId = note.id,
accountViewModel = accountViewModel,
nav = nav
)
}
composable<Route.NewPost> { backStackEntry ->
val newPost = backStackEntry.toRoute<Route.NewPost>()
NewPostScreen(
initialMessage = newPost.message,
initialAttachment = newPost.attachment,
replyTo = newPost.replyTo,
accountViewModel = accountViewModel,
onPost = { nav.popBack() }
)
}
}
}
```
### Custom Transitions
```kotlin
composable<Route.Profile>(
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300)
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300)
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(300)
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(300)
)
}
) { backStackEntry ->
val profile = backStackEntry.toRoute<Route.Profile>()
ProfileScreen(profile.pubkey, accountViewModel, nav)
}
```
## Navigation Manager
### Nav Wrapper Class
```kotlin
class Nav(
val controller: NavHostController,
val drawerState: DrawerState,
val scope: CoroutineScope
) {
/**
* Navigate to a route, closing drawer if open
*/
fun nav(route: Route) {
scope.launch {
if (!controller.popBackStack(route, inclusive = false)) {
controller.navigate(route) {
launchSingleTop = true
}
}
drawerState.close()
}
}
/**
* Navigate with new stack (clear back stack to Home)
*/
fun newStack(route: Route) {
scope.launch {
controller.navigate(route) {
popUpTo(Route.Home) {
inclusive = false
}
launchSingleTop = true
}
drawerState.close()
}
}
/**
* Pop back stack
*/
fun popBack() {
controller.popBackStack()
}
/**
* Pop up to specific route
*/
inline fun <reified T : Route> popUpTo(inclusive: Boolean = false) {
controller.popBackStack<T>(inclusive = inclusive)
}
/**
* Get current route
*/
fun currentRoute(): Route? {
return controller.currentBackStackEntry?.toRoute<Route>()
}
}
```
## Bottom Navigation
### Material3 NavigationBar
```kotlin
@Composable
fun AppBottomBar(
currentRoute: Route?,
nav: Nav
) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
) {
BottomBarRoute.entries.forEach { item ->
NavigationBarItem(
selected = currentRoute?.let { it::class == item.route::class } ?: false,
onClick = { nav.nav(item.route) },
icon = {
Icon(
imageVector = if (currentRoute?.let { it::class == item.route::class } == true) {
item.selectedIcon
} else {
item.unselectedIcon
},
contentDescription = item.label
)
},
label = { Text(item.label) },
alwaysShowLabel = false
)
}
}
}
enum class BottomBarRoute(
val route: Route,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val label: String
) {
HOME(
route = Route.Home,
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home,
label = "Home"
),
MESSAGES(
route = Route.Messages,
selectedIcon = Icons.Filled.Message,
unselectedIcon = Icons.Outlined.Message,
label = "Messages"
),
VIDEOS(
route = Route.Video,
selectedIcon = Icons.Filled.VideoLibrary,
unselectedIcon = Icons.Outlined.VideoLibrary,
label = "Videos"
),
DISCOVER(
route = Route.Discover,
selectedIcon = Icons.Filled.Explore,
unselectedIcon = Icons.Outlined.Explore,
label = "Discover"
),
NOTIFICATIONS(
route = Route.Notification,
selectedIcon = Icons.Filled.Notifications,
unselectedIcon = Icons.Outlined.Notifications,
label = "Notifications"
)
}
```
### Observing Current Route
```kotlin
@Composable
fun MainScreen() {
val navController = rememberNavController()
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.toRoute<Route>()
Scaffold(
topBar = {
if (shouldShowTopBar(currentRoute)) {
AppTopBar(currentRoute)
}
},
bottomBar = {
if (shouldShowBottomBar(currentRoute)) {
AppBottomBar(currentRoute, nav)
}
}
) { paddingValues ->
AppNavigation(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
fun shouldShowBottomBar(route: Route?): Boolean {
return when (route) {
is Route.Home,
is Route.Messages,
is Route.Video,
is Route.Discover,
is Route.Notification -> true
else -> false
}
}
```
## Navigation Drawer
### Material3 ModalDrawerSheet
```kotlin
@Composable
fun AppDrawer(
drawerState: DrawerState,
nav: Nav,
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
ModalDrawerSheet {
// User profile header
DrawerHeader(accountViewModel.account)
HorizontalDivider()
// Menu items
NavigationDrawerItem(
label = { Text("Home") },
selected = false,
onClick = { nav.nav(Route.Home) },
icon = { Icon(Icons.Default.Home, "Home") }
)
NavigationDrawerItem(
label = { Text("Profile") },
selected = false,
onClick = { nav.nav(Route.Profile(accountViewModel.account.pubkey)) },
icon = { Icon(Icons.Default.Person, "Profile") }
)
NavigationDrawerItem(
label = { Text("Settings") },
selected = false,
onClick = { nav.nav(Route.Settings) },
icon = { Icon(Icons.Default.Settings, "Settings") }
)
HorizontalDivider()
NavigationDrawerItem(
label = { Text("Logout") },
selected = false,
onClick = {
scope.launch {
accountViewModel.logout()
drawerState.close()
}
},
icon = { Icon(Icons.Default.Logout, "Logout") }
)
}
}
```
### Main Scaffold with Drawer
```kotlin
@Composable
fun MainScreen() {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val nav = remember { Nav(navController, drawerState, scope) }
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AppDrawer(drawerState, nav, accountViewModel)
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Amethyst") },
navigationIcon = {
IconButton(
onClick = { scope.launch { drawerState.open() } }
) {
Icon(Icons.Default.Menu, "Menu")
}
}
)
},
bottomBar = { AppBottomBar(currentRoute, nav) }
) { paddingValues ->
AppNavigation(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
}
```
## Deep Link Handling
### Intent Processing
```kotlin
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel
) {
val activity = LocalContext.current as? Activity
// Handle incoming intents
LaunchedEffect(activity?.intent) {
activity?.intent?.let { intent ->
handleIntent(intent, navController)
}
}
NavHost(navController = navController) {
// Routes...
}
}
fun handleIntent(intent: Intent, navController: NavHostController) {
when (intent.action) {
Intent.ACTION_SEND -> {
// Share text/image
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
val sharedUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
navController.navigate(
Route.NewPost(
message = sharedText,
attachment = sharedUri?.toString()
)
)
}
Intent.ACTION_VIEW -> {
// Deep link
intent.data?.let { uri ->
when (uri.scheme) {
"nostr" -> handleNostrUri(uri, navController)
"https", "http" -> handleWebUri(uri, navController)
}
}
}
}
}
fun handleNostrUri(uri: Uri, navController: NavHostController) {
val path = uri.pathSegments.firstOrNull() ?: return
when {
path.startsWith("npub") -> {
navController.navigate(Route.Profile(path))
}
path.startsWith("note") -> {
navController.navigate(Route.Note(path))
}
path.startsWith("nevent") -> {
// Decode and navigate to event
val eventId = decodeNevent(path)
navController.navigate(Route.Note(eventId))
}
}
}
fun handleWebUri(uri: Uri, navController: NavHostController) {
// Handle web-based deep links
// https://njump.me/npub1...
// https://primal.net/profile/npub1...
when (uri.host) {
"njump.me" -> {
val id = uri.pathSegments.lastOrNull()
if (id?.startsWith("npub") == true) {
navController.navigate(Route.Profile(id))
}
}
"primal.net" -> {
// Parse primal.net URLs
}
}
}
```
### AndroidManifest Intent Filters
```xml
<!-- MainActivity -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostr" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="njump.me" />
<data android:scheme="https" android:host="primal.net" />
<data android:scheme="https" android:host="iris.to" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
</intent-filter>
```
## Nested Navigation
### Tab Navigation Inside Screen
```kotlin
@Composable
fun ProfileScreen(
pubkey: String,
nav: Nav
) {
val nestedNavController = rememberNavController()
Column {
ProfileHeader(pubkey)
// Tab row
TabRow(selectedTabIndex = currentTab) {
Tab(selected = currentTab == 0, onClick = { /* Notes */ })
Tab(selected = currentTab == 1, onClick = { /* Replies */ })
Tab(selected = currentTab == 2, onClick = { /* Likes */ })
}
// Nested NavHost for tabs
NavHost(
navController = nestedNavController,
startDestination = ProfileTab.Notes
) {
composable<ProfileTab.Notes> {
NotesTabContent(pubkey)
}
composable<ProfileTab.Replies> {
RepliesTabContent(pubkey)
}
composable<ProfileTab.Likes> {
LikesTabContent(pubkey)
}
}
}
}
@Serializable
sealed class ProfileTab {
@Serializable object Notes : ProfileTab()
@Serializable object Replies : ProfileTab()
@Serializable object Likes : ProfileTab()
}
```
## Testing Navigation
### Navigation Test Example
```kotlin
@Test
fun testNavigationToProfile() {
val navController = TestNavHostController(
ApplicationProvider.getApplicationContext()
)
composeTestRule.setContent {
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
AppNavigation(navController, accountViewModel)
}
// Navigate to profile
composeTestRule.onNodeWithText("Profile").performClick()
// Verify navigation
val currentRoute = navController.currentBackStackEntry?.toRoute<Route>()
assertTrue(currentRoute is Route.Profile)
}
```
## File Locations
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Nav.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt`
@@ -0,0 +1,659 @@
# Android Runtime Permissions
Complete permission handling patterns for Amethyst using Accompanist Permissions library and Android best practices.
## Permission Categories in Amethyst
### Network Permissions (Normal - Auto-granted)
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
```
### Media Permissions (Dangerous - Runtime request)
```xml
<!-- Camera -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Audio -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Storage (version-specific) -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
```
### Notification Permissions (Android 13+)
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```
### Location Permissions
```xml
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
```
### NFC Permissions
```xml
<uses-permission android:name="android.permission.NFC" />
```
### Foreground Service Permissions
```xml
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
```
## Accompanist Permissions Library
### Setup
```gradle
dependencies {
implementation("com.google.accompanist:accompanist-permissions:0.36.0")
}
```
### Single Permission Pattern
```kotlin
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.shouldShowRationale
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraFeature() {
val cameraPermissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
when {
// Permission granted - show feature
cameraPermissionState.status.isGranted -> {
CameraPreview()
}
// Should show rationale - explain why permission is needed
cameraPermissionState.status.shouldShowRationale -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Camera permission is needed to scan QR codes for login",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { cameraPermissionState.launchPermissionRequest() }
) {
Text("Grant Permission")
}
}
}
// First time - request permission
else -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(
onClick = { cameraPermissionState.launchPermissionRequest() }
) {
Icon(Icons.Default.CameraAlt, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Enable Camera")
}
}
}
}
}
```
### Multiple Permissions Pattern
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaUploadFeature() {
val permissionsState = rememberMultiplePermissionsState(
permissions = buildList {
add(Manifest.permission.CAMERA)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
)
when {
// All permissions granted
permissionsState.allPermissionsGranted -> {
MediaUploadUI()
}
// Some permissions need rationale
permissionsState.shouldShowRationale -> {
RationaleDialog(
title = "Permissions Required",
message = "Camera and storage access are needed to upload photos",
onConfirm = {
permissionsState.launchMultiplePermissionRequest()
},
onDismiss = { /* Handle dismissal */ }
)
}
// Request all permissions
else -> {
PermissionRequestScreen(
permissions = permissionsState.permissions,
onRequestPermissions = {
permissionsState.launchMultiplePermissionRequest()
}
)
}
}
}
@Composable
fun RationaleDialog(
title: String,
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Continue")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
```
## Lifecycle-Aware Permission Requests
### Amethyst Pattern: POST_NOTIFICATIONS
**File:** `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt`
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NotificationRegistration(accountViewModel: AccountViewModel) {
val context = LocalContext.current
// Only request on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(
Manifest.permission.POST_NOTIFICATIONS
)
// Register for push notifications when permission is granted
if (notificationPermissionState.status.isGranted) {
LifecycleResumeEffect(
key1 = accountViewModel,
key2 = notificationPermissionState.status.isGranted
) {
val scope = rememberCoroutineScope()
scope.launch(Dispatchers.IO) {
PushNotificationUtils.checkAndInit(
context = context,
accountViewModel = accountViewModel
)
}
onPauseOrDispose {
// Cleanup when composable pauses or disposes
}
}
} else {
// Show prompt to enable notifications
NotificationPermissionPrompt(
onEnableClick = {
notificationPermissionState.launchPermissionRequest()
}
)
}
}
}
@Composable
fun NotificationPermissionPrompt(onEnableClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enable Notifications",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Get notified when someone mentions you or replies to your posts",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onEnableClick,
modifier = Modifier.fillMaxWidth()
) {
Text("Enable Notifications")
}
}
}
}
```
## Permission Best Practices
### 1. Request Contextually
**Bad:**
```kotlin
// Requesting permission on app launch
@Composable
fun AppContent() {
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
// DON'T DO THIS - user doesn't know why
permissionState.launchPermissionRequest()
}
}
```
**Good:**
```kotlin
// Request when user explicitly wants to use camera
@Composable
fun QRScannerButton() {
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
Button(
onClick = {
if (permissionState.status.isGranted) {
// Open scanner
} else {
// Request permission
permissionState.launchPermissionRequest()
}
}
) {
Text("Scan QR Code")
}
}
```
### 2. Show Rationale
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun LocationFeature() {
val locationPermissionState = rememberPermissionState(
Manifest.permission.ACCESS_COARSE_LOCATION
)
// Always show rationale first for sensitive permissions
if (!locationPermissionState.status.isGranted) {
LocationRationaleCard(
onEnableClick = {
locationPermissionState.launchPermissionRequest()
}
)
} else {
LocationMap()
}
}
@Composable
fun LocationRationaleCard(onEnableClick: () -> Unit) {
Card {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Why location access?",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Location is used for geohashing your posts. " +
"This helps other users discover local content. " +
"Your exact location is never shared.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Row {
OutlinedButton(onClick = { /* Skip */ }) {
Text("Skip")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onEnableClick) {
Text("Enable")
}
}
}
}
}
```
### 3. Handle Permanent Denial
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraFeatureWithSettings() {
val context = LocalContext.current
val cameraPermissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
when {
cameraPermissionState.status.isGranted -> {
CameraPreview()
}
cameraPermissionState.status.shouldShowRationale -> {
// User denied once, show rationale
RationaleDialog(
onConfirm = { cameraPermissionState.launchPermissionRequest() }
)
}
else -> {
// Might be permanently denied - offer settings
PermanentlyDeniedDialog(
onOpenSettings = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
)
}
}
}
@Composable
fun PermanentlyDeniedDialog(onOpenSettings: () -> Unit) {
AlertDialog(
onDismissRequest = { },
title = { Text("Permission Denied") },
text = {
Text(
"Camera permission is required for QR scanning. " +
"Please enable it in Settings."
)
},
confirmButton = {
TextButton(onClick = onOpenSettings) {
Text("Open Settings")
}
},
dismissButton = {
TextButton(onClick = { /* Cancel */ }) {
Text("Cancel")
}
}
)
}
```
### 4. Version-Specific Permissions
```kotlin
@Composable
fun StoragePermissionRequest() {
val permissions = remember {
buildList {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
add(Manifest.permission.READ_MEDIA_IMAGES)
add(Manifest.permission.READ_MEDIA_VIDEO)
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
// Android 10-12: No permission needed for scoped storage
}
else -> {
// Android 9 and below
add(Manifest.permission.READ_EXTERNAL_STORAGE)
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
}
}
if (permissions.isNotEmpty()) {
val permissionsState = rememberMultiplePermissionsState(permissions)
if (!permissionsState.allPermissionsGranted) {
StoragePermissionUI(
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
} else {
MediaPickerUI()
}
} else {
// No permission needed
MediaPickerUI()
}
}
```
## Permission Groups
### Camera + Storage (Media Upload)
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaCaptureFeature() {
val mediaPermissions = buildList {
add(Manifest.permission.CAMERA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.READ_MEDIA_IMAGES)
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
val permissionsState = rememberMultiplePermissionsState(mediaPermissions)
when {
permissionsState.allPermissionsGranted -> {
MediaCaptureUI()
}
else -> {
MediaPermissionScreen(
permissions = permissionsState.permissions,
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
}
}
}
```
### Audio + Storage (Voice Recording)
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun VoiceRecordingFeature() {
val audioPermissions = buildList {
add(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.READ_MEDIA_AUDIO)
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
val permissionsState = rememberMultiplePermissionsState(audioPermissions)
if (permissionsState.allPermissionsGranted) {
AudioRecorderUI()
} else {
AudioPermissionScreen(
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
}
}
```
## Testing Permissions
### Grant Permission in Tests
```kotlin
@get:Rule
val permissionRule = GrantPermissionRule.grant(
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE
)
@Test
fun testCameraFeatureWithPermission() {
composeTestRule.setContent {
CameraFeature()
}
// Permission is already granted by rule
composeTestRule.onNodeWithText("Take Photo").assertExists()
}
```
### Test Permission Request Flow
```kotlin
@Test
fun testPermissionRequestFlow() {
composeTestRule.setContent {
CameraFeature()
}
// Initially shows permission request button
composeTestRule.onNodeWithText("Enable Camera").assertExists()
// Click to request
composeTestRule.onNodeWithText("Enable Camera").performClick()
// System permission dialog appears (can't test dialog itself)
// Would need UiAutomator to interact with system dialog
}
```
## Permission State Checking
### Check Permission Before Action
```kotlin
fun checkAndRequestCameraPermission(
context: Context,
permissionState: PermissionState,
onGranted: () -> Unit
) {
when {
permissionState.status.isGranted -> {
onGranted()
}
permissionState.status.shouldShowRationale -> {
// Show rationale dialog
}
else -> {
permissionState.launchPermissionRequest()
}
}
}
```
### Manual Permission Check (Non-Compose)
```kotlin
fun hasCameraPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
}
fun requestCameraPermission(activity: ComponentActivity) {
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.CAMERA),
REQUEST_CAMERA_PERMISSION
)
}
// In Activity
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_CAMERA_PERMISSION -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permission granted
} else {
// Permission denied
}
}
}
}
companion object {
private const val REQUEST_CAMERA_PERMISSION = 100
}
```
## File Locations
- `amethyst/src/main/AndroidManifest.xml` - Permission declarations
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt` - Notification permission pattern
- `amethyst/build.gradle` - Accompanist dependency
## Resources
- [Accompanist Permissions Documentation](https://google.github.io/accompanist/permissions/)
- [Android Permissions Guide](https://developer.android.com/guide/topics/permissions/overview)
- [Request Runtime Permissions](https://developer.android.com/training/permissions/requesting)
@@ -0,0 +1,468 @@
# Proguard Rules for Amethyst
Proguard configuration for optimizing and obfuscating Android APK while preserving necessary code.
## What is Proguard/R8?
**R8** is Android's default code shrinker and obfuscator (replaced Proguard in AGP 3.4.0+). It:
- **Shrinks** code by removing unused classes/methods
- **Obfuscates** code by renaming classes/methods to short names
- **Optimizes** code by inlining methods and removing dead code
## Amethyst Proguard Configuration
**File:** `amethyst/proguard-rules.pro`
### Keep Kotlin Metadata
```proguard
# Kotlin metadata is required for reflection
-keep class kotlin.Metadata { *; }
-keep class kotlin.** { *; }
-dontwarn kotlin.**
# Kotlin serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class com.vitorpamplona.**$$serializer { *; }
-keepclassmembers class com.vitorpamplona.** {
*** Companion;
}
-keepclasseswithmembers class com.vitorpamplona.** {
kotlinx.serialization.KSerializer serializer(...);
}
```
### Keep Nostr Event Classes
```proguard
# Nostr events are serialized/deserialized
-keep class com.vitorpamplona.quartz.events.** { *; }
-keep class com.vitorpamplona.quartz.encoders.** { *; }
# Keep event builders
-keep class com.vitorpamplona.quartz.builders.** { *; }
# Keep tag classes
-keep class com.vitorpamplona.quartz.nip01Core.tags.** { *; }
```
### Keep Data Classes
```proguard
# Data classes used in ViewModels and serialization
-keep @kotlinx.serialization.Serializable class * { *; }
# Keep all data classes
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
```
### Keep Compose Classes
```proguard
# Jetpack Compose
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**
# Compose runtime
-keep class androidx.compose.runtime.** { *; }
# Compose UI
-keep class androidx.compose.ui.** { *; }
# Material3
-keep class androidx.compose.material3.** { *; }
# Navigation Compose - Keep serializable routes
-keep class * implements java.io.Serializable { *; }
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
!static !transient <fields>;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
```
### Keep OkHttp/Retrofit
```proguard
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
# OkHttp WebSockets (for Nostr relays)
-keep class okhttp3.internal.ws.** { *; }
# Retrofit (if used)
-keepattributes Signature
-keepattributes Exceptions
-keep class retrofit2.** { *; }
```
### Keep Jackson (JSON)
```proguard
# Jackson JSON library
-keep class com.fasterxml.jackson.** { *; }
-keep class org.codehaus.** { *; }
-keepclassmembers class * {
@com.fasterxml.jackson.annotation.* <methods>;
}
# Jackson polymorphic types
-keepattributes RuntimeVisibleAnnotations
-keep @com.fasterxml.jackson.annotation.JsonTypeInfo class *
```
### Keep Secp256k1 (Crypto)
```proguard
# Secp256k1 native library
-keep class fr.acinq.secp256k1.** { *; }
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
```
### Keep Tor
```proguard
# Tor library
-keep class com.msopentech.thali.toronionproxy.** { *; }
-dontwarn com.msopentech.thali.toronionproxy.**
```
### Keep ExoPlayer (Media)
```proguard
# ExoPlayer (Media3)
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
-keep class com.google.android.exoplayer2.** { *; }
-dontwarn com.google.android.exoplayer2.**
```
### Keep Coil (Image Loading)
```proguard
# Coil image loading
-keep class coil.** { *; }
-keep class coil3.** { *; }
-dontwarn coil.**
-dontwarn coil3.**
```
### Keep ViewModels
```proguard
# ViewModel classes
-keep class * extends androidx.lifecycle.ViewModel {
<init>();
}
# ViewModel factories
-keep class * extends androidx.lifecycle.ViewModelProvider$Factory {
<init>(...);
}
# Keep ViewModel constructors for reflection
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
<init>(...);
}
```
### Keep Parcelable
```proguard
# Parcelable
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
-keepclassmembers class * implements android.os.Parcelable {
public <fields>;
private <fields>;
}
```
### Keep Enums
```proguard
# Enums
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
```
### Remove Logging (Production)
```proguard
# Remove debug logging in release builds
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
# Keep error/warning logs
-assumenosideeffects class android.util.Log {
public static *** e(...) return false;
public static *** w(...) return false;
}
```
### Keep Crashlytics/Firebase
```proguard
# Firebase Crashlytics
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
# Firebase
-keep class com.google.firebase.** { *; }
-dontwarn com.google.firebase.**
```
## Build Configuration
### Enable R8 in build.gradle
```gradle
android {
buildTypes {
release {
minifyEnabled = true
shrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
minifyEnabled = false
}
}
}
```
### Multiple Proguard Files
```gradle
android {
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
"proguard-quartz.pro", // Library-specific rules
"proguard-compose.pro" // Compose-specific rules
)
}
}
}
```
## Debugging Proguard Issues
### Generate Mapping File
R8 generates `mapping.txt` in `app/build/outputs/mapping/release/`:
```
# Original class name -> Obfuscated name
com.vitorpamplona.amethyst.ui.MainActivity -> a.b.c:
void onCreate(Bundle) -> a
```
### Deobfuscate Stack Traces
```bash
# Using retrace (part of Android SDK)
retrace.sh mapping.txt stacktrace.txt
```
### Enable Proguard Output
```gradle
android {
buildTypes {
release {
proguardFiles(...)
// Generate reports
postprocessing {
proguardFiles = [...]
obfuscate = true
optimizeCode = true
removeUnusedCode = true
}
}
}
}
```
**Output files:**
- `build/outputs/mapping/release/configuration.txt` - All Proguard rules applied
- `build/outputs/mapping/release/mapping.txt` - Obfuscation mappings
- `build/outputs/mapping/release/seeds.txt` - Classes kept by `-keep` rules
- `build/outputs/mapping/release/usage.txt` - Code removed by R8
### Test Release Build
```bash
./gradlew assembleRelease
# Install and test
adb install app/build/outputs/apk/release/app-release.apk
```
## Common Issues
### Issue: NoSuchMethodException at Runtime
**Cause:** Proguard removed or renamed a method used via reflection.
**Solution:**
```proguard
-keep class com.example.YourClass {
public <methods>;
}
```
### Issue: Serialization Fails
**Cause:** Data class fields were renamed.
**Solution:**
```proguard
-keep @kotlinx.serialization.Serializable class * { *; }
-keepclassmembers class * {
@kotlinx.serialization.SerialName <fields>;
}
```
### Issue: Compose Navigation Crashes
**Cause:** @Serializable route classes were obfuscated.
**Solution:**
```proguard
# Keep all route classes
-keep @kotlinx.serialization.Serializable class com.vitorpamplona.amethyst.ui.navigation.routes.** { *; }
```
### Issue: Native Library Crashes
**Cause:** Native method signatures were changed.
**Solution:**
```proguard
-keepclasseswithmembernames class * {
native <methods>;
}
```
## Optimization Tips
### 1. Keep Only What's Necessary
Don't use broad wildcards:
```proguard
# Bad - keeps everything
-keep class com.vitorpamplona.** { *; }
# Good - keeps only specific packages
-keep class com.vitorpamplona.quartz.events.** { *; }
```
### 2. Test Thoroughly
- Test all app features after enabling Proguard
- Test deep links and navigation
- Test serialization/deserialization
- Test external library integrations
### 3. Use AGP's Proguard Analysis
```gradle
android {
buildTypes {
release {
// Generate R8 configuration
android.debug.obsoleteApi = true
}
}
}
```
### 4. Analyze APK Size
```bash
# Build release APK
./gradlew assembleRelease
# Analyze APK with Android Studio
# Build > Analyze APK > Select app-release.apk
```
See `scripts/analyze-apk-size.sh` for automated analysis.
## Product Flavor Specific Rules
### Play Flavor (Firebase)
```proguard
# proguard-play.pro
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
```
### F-Droid Flavor (No Google Services)
```proguard
# proguard-fdroid.pro
# UnifiedPush
-keep class org.unifiedpush.** { *; }
```
**Configure in build.gradle:**
```gradle
android {
flavorDimensions = ["channel"]
productFlavors {
create("play") {
dimension = "channel"
proguardFiles("proguard-play.pro")
}
create("fdroid") {
dimension = "channel"
proguardFiles("proguard-fdroid.pro")
}
}
}
```
## File Locations
- `amethyst/proguard-rules.pro` - Main Proguard rules
- `amethyst/build/outputs/mapping/release/` - Proguard output files
- `amethyst/build.gradle` - Proguard configuration
## Resources
- [Android R8 Documentation](https://developer.android.com/build/shrink-code)
- [Proguard Manual](https://www.guardsquare.com/manual/configuration)
- [Kotlinx Serialization Proguard](https://github.com/Kotlin/kotlinx.serialization#android)
+230
View File
@@ -0,0 +1,230 @@
#!/bin/bash
#
# APK Size Analysis Script for Amethyst
#
# Usage:
# ./analyze-apk-size.sh [apk-path]
#
# If no APK path provided, uses latest release build
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default APK path
DEFAULT_APK="amethyst/build/outputs/apk/release/amethyst-release.apk"
APK_PATH="${1:-$DEFAULT_APK}"
# Check if APK exists
if [ ! -f "$APK_PATH" ]; then
echo -e "${RED}Error: APK not found at $APK_PATH${NC}"
echo "Build the APK first: ./gradlew :amethyst:assembleRelease"
exit 1
fi
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Amethyst APK Size Analysis${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo
# APK basic info
echo -e "${GREEN}APK Path:${NC} $APK_PATH"
APK_SIZE=$(du -h "$APK_PATH" | cut -f1)
APK_SIZE_BYTES=$(stat -f%z "$APK_PATH" 2>/dev/null || stat -c%s "$APK_PATH" 2>/dev/null)
echo -e "${GREEN}APK Size:${NC} $APK_SIZE ($APK_SIZE_BYTES bytes)"
echo
# Extract APK to temp directory
TEMP_DIR=$(mktemp -d)
echo -e "${YELLOW}Extracting APK to $TEMP_DIR...${NC}"
unzip -q "$APK_PATH" -d "$TEMP_DIR"
# Analyze APK contents
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Top-Level Contents${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
du -sh "$TEMP_DIR"/* | sort -hr
# Analyze DEX files
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} DEX Files (Code)${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
DEX_TOTAL=0
for dex in "$TEMP_DIR"/*.dex; do
if [ -f "$dex" ]; then
DEX_NAME=$(basename "$dex")
DEX_SIZE=$(stat -f%z "$dex" 2>/dev/null || stat -c%s "$dex" 2>/dev/null)
DEX_SIZE_MB=$(echo "scale=2; $DEX_SIZE / 1024 / 1024" | bc)
echo -e " $DEX_NAME: ${GREEN}${DEX_SIZE_MB} MB${NC}"
DEX_TOTAL=$((DEX_TOTAL + DEX_SIZE))
fi
done
DEX_TOTAL_MB=$(echo "scale=2; $DEX_TOTAL / 1024 / 1024" | bc)
echo -e "${YELLOW}Total DEX: $DEX_TOTAL_MB MB${NC}"
# Analyze resources
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Resources${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
if [ -d "$TEMP_DIR/res" ]; then
RES_SIZE=$(du -sh "$TEMP_DIR/res" | cut -f1)
echo -e " res/: ${GREEN}$RES_SIZE${NC}"
# Top resource folders
echo " Top resource folders:"
du -sh "$TEMP_DIR/res"/* | sort -hr | head -10 | sed 's/^/ /'
fi
# Analyze assets
echo
if [ -d "$TEMP_DIR/assets" ]; then
ASSETS_SIZE=$(du -sh "$TEMP_DIR/assets" | cut -f1)
echo -e " assets/: ${GREEN}$ASSETS_SIZE${NC}"
# Top asset files
echo " Top asset files:"
find "$TEMP_DIR/assets" -type f -exec du -h {} \; | sort -hr | head -10 | sed 's/^/ /'
fi
# Analyze native libraries
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Native Libraries${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
if [ -d "$TEMP_DIR/lib" ]; then
LIB_SIZE=$(du -sh "$TEMP_DIR/lib" | cut -f1)
echo -e " lib/: ${GREEN}$LIB_SIZE${NC}"
# By architecture
for arch in "$TEMP_DIR/lib"/*; do
if [ -d "$arch" ]; then
ARCH_NAME=$(basename "$arch")
ARCH_SIZE=$(du -sh "$arch" | cut -f1)
echo -e " $ARCH_NAME: ${GREEN}$ARCH_SIZE${NC}"
# Top libraries in architecture
find "$arch" -type f -name "*.so" -exec du -h {} \; | sort -hr | head -5 | sed 's/^/ /'
fi
done
else
echo " No native libraries"
fi
# Analyze Kotlin metadata
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Kotlin Metadata${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
if [ -d "$TEMP_DIR/kotlin" ]; then
KOTLIN_SIZE=$(du -sh "$TEMP_DIR/kotlin" | cut -f1)
echo -e " kotlin/: ${GREEN}$KOTLIN_SIZE${NC}"
fi
# Method count (if dexdump available)
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Method Count${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
# Try to find dexdump
DEXDUMP=$(which dexdump 2>/dev/null || find "$ANDROID_HOME/build-tools" -name dexdump 2>/dev/null | head -1 || echo "")
if [ -n "$DEXDUMP" ] && [ -x "$DEXDUMP" ]; then
TOTAL_METHODS=0
for dex in "$TEMP_DIR"/*.dex; do
if [ -f "$dex" ]; then
DEX_NAME=$(basename "$dex")
METHOD_COUNT=$("$DEXDUMP" -l xml "$dex" | grep -c "<method " || echo "0")
echo -e " $DEX_NAME: ${GREEN}$METHOD_COUNT methods${NC}"
TOTAL_METHODS=$((TOTAL_METHODS + METHOD_COUNT))
fi
done
echo -e "${YELLOW}Total methods: $TOTAL_METHODS${NC}"
# Check multidex threshold
if [ $TOTAL_METHODS -gt 65536 ]; then
echo -e "${RED} ⚠ Exceeded 64K method limit (multidex required)${NC}"
else
REMAINING=$((65536 - TOTAL_METHODS))
echo -e "${GREEN} ✓ Below 64K limit ($REMAINING methods remaining)${NC}"
fi
else
echo -e "${YELLOW} dexdump not found - cannot analyze method count${NC}"
echo " Set ANDROID_HOME or install Android SDK build-tools"
fi
# Size breakdown percentages
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Size Breakdown${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
# Calculate percentages
CODE_SIZE=$(du -s "$TEMP_DIR"/*.dex 2>/dev/null | awk '{sum+=$1} END {print sum}' || echo "0")
RES_SIZE_BYTES=$(du -s "$TEMP_DIR/res" 2>/dev/null | awk '{print $1}' || echo "0")
ASSETS_SIZE_BYTES=$(du -s "$TEMP_DIR/assets" 2>/dev/null | awk '{print $1}' || echo "0")
LIB_SIZE_BYTES=$(du -s "$TEMP_DIR/lib" 2>/dev/null | awk '{print $1}' || echo "0")
# Convert to KB for bc
APK_SIZE_KB=$((APK_SIZE_BYTES / 1024))
CODE_PCT=$(echo "scale=1; $CODE_SIZE * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
RES_PCT=$(echo "scale=1; $RES_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
ASSETS_PCT=$(echo "scale=1; $ASSETS_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
LIB_PCT=$(echo "scale=1; $LIB_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0")
echo -e " Code (DEX): ${CODE_PCT}%"
echo -e " Resources: ${RES_PCT}%"
echo -e " Assets: ${ASSETS_PCT}%"
echo -e " Native libs: ${LIB_PCT}%"
# Recommendations
echo
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} Optimization Recommendations${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
# Check if resources are large
if [ $(echo "$RES_PCT > 30" | bc 2>/dev/null || echo "0") -eq 1 ]; then
echo -e "${YELLOW} • Resources are ${RES_PCT}% of APK - consider:${NC}"
echo " - Enable resource shrinking (shrinkResources = true)"
echo " - Use WebP for images instead of PNG/JPG"
echo " - Remove unused resources"
fi
# Check if native libs are large
if [ $(echo "$LIB_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then
echo -e "${YELLOW} • Native libraries are ${LIB_PCT}% of APK - consider:${NC}"
echo " - Use App Bundle to serve ABI-specific APKs"
echo " - Remove unused ABIs"
fi
# Check if code is large
if [ $(echo "$CODE_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then
echo -e "${YELLOW} • Code is ${CODE_PCT}% of APK - consider:${NC}"
echo " - Enable Proguard/R8 (minifyEnabled = true)"
echo " - Review dependencies for bloat"
echo " - Enable code shrinking"
fi
# General recommendations
echo -e "${GREEN} General optimizations:${NC}"
echo " - Use Android App Bundle (.aab) instead of APK"
echo " - Enable R8 optimization (minifyEnabled = true)"
echo " - Enable resource shrinking (shrinkResources = true)"
echo " - Analyze with APK Analyzer in Android Studio"
# Cleanup
echo
echo -e "${YELLOW}Cleaning up temporary files...${NC}"
rm -rf "$TEMP_DIR"
echo
echo -e "${GREEN}✓ Analysis complete${NC}"
+748
View File
@@ -0,0 +1,748 @@
# Desktop Expert
Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.
## When to Use This Skill
**Auto-invoke when:**
- Working with `desktopApp/` module files
- Using Desktop-only APIs: `Window`, `Tray`, `MenuBar`, `Dialog`
- Implementing keyboard shortcuts, menu systems
- Desktop navigation (NavigationRail, multi-window)
- File system operations (file pickers, drag-drop)
- OS-specific behavior (macOS, Windows, Linux)
- Desktop UX patterns (keyboard-first, tooltips)
**Delegate to:**
- **kotlin-multiplatform**: Shared code questions, `jvmMain` source set structure
- **gradle-expert**: All `build.gradle.kts` issues, dependency conflicts
- **compose-expert**: General Compose patterns, `@Composable` best practices, Material3
## Scope
**In scope:**
- Desktop-only Compose APIs
- Window management, positioning, state
- MenuBar + keyboard shortcuts (OS-specific)
- System Tray integration
- Desktop navigation patterns (NavigationRail)
- File dialogs, Desktop.getDesktop()
- OS conventions (macOS vs Windows vs Linux)
- Desktop UX principles
**Out of scope:**
- Build configuration → **gradle-expert**
- Shared composables → **compose-expert**
- KMP structure → **kotlin-multiplatform**
---
## 1. Desktop Entry Point
### application {} DSL
Desktop apps start with the `application {}` block:
```kotlin
// desktopApp/src/jvmMain/kotlin/Main.kt
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst"
) {
MenuBar { /* ... */ }
App()
}
}
```
**Key points:**
- `application {}` is the root composable (JVM-only)
- `Window()` creates the main window
- `rememberWindowState()` manages size/position
- `onCloseRequest` handles window close
**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:87-138`
---
## 2. Window Management
### WindowState
```kotlin
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
state = windowState,
title = "My App",
resizable = true,
onCloseRequest = ::exitApplication
) {
// Content
}
```
### Multiple Windows
```kotlin
fun main() = application {
var showSettings by remember { mutableStateOf(false) }
Window(onCloseRequest = ::exitApplication, title = "Main") {
Button(onClick = { showSettings = true }) {
Text("Open Settings")
}
}
if (showSettings) {
Window(
onCloseRequest = { showSettings = false },
title = "Settings"
) {
// Settings UI
}
}
}
```
**Pattern:** Use state to control window visibility conditionally.
---
## 3. MenuBar System
### Basic MenuBar
```kotlin
Window(onCloseRequest = ::exitApplication, title = "App") {
MenuBar {
Menu("File") {
Item("New Note", onClick = { /* ... */ })
Separator()
Item("Quit", onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", onClick = { /* ... */ })
Item("Paste", onClick = { /* ... */ })
}
}
App()
}
```
### Keyboard Shortcuts (OS-Aware)
**Current issue:** Main.kt hardcodes `ctrl = true` (Main.kt:105, 111, 117, 122, 123).
**OS-specific shortcuts:**
```kotlin
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
// Detect OS
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
MenuBar {
Menu("File") {
Item(
"New Note",
shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true) // Cmd+N on macOS
} else {
KeyShortcut(Key.N, ctrl = true) // Ctrl+N on Win/Linux
},
onClick = { /* ... */ }
)
Item(
"Settings",
shortcut = if (isMacOS) {
KeyShortcut(Key.Comma, meta = true) // Cmd+, on macOS
} else {
KeyShortcut(Key.Comma, ctrl = true) // Ctrl+, on Win/Linux
},
onClick = { /* ... */ }
)
Separator()
Item(
"Quit",
shortcut = if (isMacOS) {
KeyShortcut(Key.Q, meta = true) // Cmd+Q on macOS
} else {
KeyShortcut(Key.Q, ctrl = true) // Ctrl+Q on Win/Linux
},
onClick = ::exitApplication
)
}
}
```
**Standard shortcuts:**
| Action | macOS | Windows/Linux |
|--------|-------|---------------|
| New | Cmd+N | Ctrl+N |
| Open | Cmd+O | Ctrl+O |
| Save | Cmd+S | Ctrl+S |
| Quit | Cmd+Q | Ctrl+Q (Alt+F4) |
| Settings | Cmd+, | Ctrl+, |
| Copy | Cmd+C | Ctrl+C |
| Paste | Cmd+V | Ctrl+V |
| Undo | Cmd+Z | Ctrl+Z |
**See:** `references/keyboard-shortcuts.md` for full list.
---
## 4. System Tray
### Basic Tray
```kotlin
application {
var isVisible by remember { mutableStateOf(true) }
Tray(
icon = painterResource("icon.png"),
onAction = { isVisible = true },
menu = {
Item("Show", onClick = { isVisible = true })
Separator()
Item("Quit", onClick = ::exitApplication)
}
)
if (isVisible) {
Window(
onCloseRequest = { isVisible = false }, // Minimize to tray
title = "App"
) {
// Content
}
}
}
```
**Pattern:** Hide window to tray instead of closing.
**Current status:** Not implemented in Main.kt. Planned feature.
---
## 5. Desktop Navigation Patterns
### NavigationRail (Current Pattern)
Desktop uses **NavigationRail** (vertical sidebar) instead of Android's bottom navigation.
```kotlin
Row(Modifier.fillMaxSize()) {
// Sidebar
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { currentScreen = AppScreen.Feed }
)
// More items...
}
VerticalDivider()
// Main content area
Box(Modifier.weight(1f).fillMaxHeight()) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen()
// Other screens...
}
}
}
```
**See:** Main.kt:191-264
**Why NavigationRail?**
- Desktop has horizontal space (1200+ dp width)
- Vertical sidebar is standard desktop pattern
- Always visible (no tabs hidden)
- Icon + label both visible
**Android comparison:**
- Android: `BottomNavigationBar` (horizontal, bottom)
- Desktop: `NavigationRail` (vertical, left)
### Multi-Pane Layouts
Desktop can leverage wide screens:
```kotlin
Row {
// Left: Navigation
NavigationRail { /* ... */ }
// Center: Main content
Box(Modifier.weight(0.6f)) {
FeedScreen()
}
// Right: Details pane (desktop only)
if (selectedNote != null) {
VerticalDivider()
Box(Modifier.weight(0.4f)) {
NoteDetailPane(selectedNote)
}
}
}
```
**See:** `references/desktop-navigation.md`
---
## 6. File System Integration
### File Dialogs
```kotlin
// File picker (load)
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
fileDialog.isVisible = true
val filePath = fileDialog.file?.let { "${fileDialog.directory}$it" }
// File picker (save)
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
saveDialog.isVisible = true
val savePath = saveDialog.file?.let { "${saveDialog.directory}$it" }
```
**Note:** Compose Desktop doesn't have native file picker composable yet. Use AWT `FileDialog`.
### Open External URLs
```kotlin
// jvmMain actual implementation
actual fun openExternalUrl(url: String) {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI(url))
}
}
```
**Pattern:** Define `expect` in `commonMain`, implement `actual` in `jvmMain`.
### Drag & Drop (Future)
```kotlin
// Compose Desktop drag-drop (experimental)
Box(
modifier = Modifier
.onExternalDrag(
onDragStart = { /* ... */ },
onDrag = { /* ... */ },
onDragExit = { /* ... */ },
onDrop = { state ->
val dragData = state.dragData
// Handle dropped files
}
)
) {
Text("Drop files here")
}
```
---
## 7. OS-Specific Conventions
### Platform Detection
```kotlin
val osName = System.getProperty("os.name").lowercase()
val isMacOS = osName.contains("mac")
val isWindows = osName.contains("win")
val isLinux = osName.contains("nux") || osName.contains("nix")
```
### Menu Bar Placement
| OS | Behavior |
|----|----------|
| **macOS** | System-wide menu bar at top of screen |
| **Windows** | In-window menu bar |
| **Linux** | Varies by desktop environment |
Compose Desktop `MenuBar` adapts automatically.
### Keyboard Modifier Keys
| Modifier | macOS | Windows/Linux |
|----------|-------|---------------|
| Primary | `meta = true` (Cmd) | `ctrl = true` |
| Secondary | `ctrl = true` | `alt = true` |
| Shift | `shift = true` | `shift = true` |
**Best practice:** Detect OS and use appropriate modifier.
### System Tray Behavior
| OS | Tray Location |
|----|---------------|
| **macOS** | Top-right menu bar |
| **Windows** | Bottom-right taskbar |
| **Linux** | Top panel (varies) |
---
## 8. Desktop UX Principles
### Keyboard-First Design
**Every action should have:**
1. Mouse/touch interaction
2. Keyboard shortcut (if frequent)
3. Tooltip showing shortcut
```kotlin
IconButton(
onClick = { /* refresh */ },
modifier = Modifier.tooltipArea(
tooltip = {
Text("Refresh (${if (isMacOS) "Cmd" else "Ctrl"}+R)")
}
)
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
### Tooltip Best Practices
- Show keyboard shortcut in tooltip
- Use native modifier name (Cmd vs Ctrl)
- Brief description + shortcut
### Context Menus
Right-click should show context menu:
```kotlin
// Future: Compose Desktop context menu API
Box(
modifier = Modifier.contextMenuArea(
items = {
listOf(
ContextMenuItem("Copy") { /* ... */ },
ContextMenuItem("Paste") { /* ... */ }
)
}
)
) {
// Content
}
```
**Current:** Use popup or custom implementation.
### Window State Persistence
Save/restore window size/position:
```kotlin
// Save on close
windowState.size // DpSize
windowState.position // WindowPosition
// Restore on launch
val savedWidth = preferences.getInt("window.width", 1200)
val savedHeight = preferences.getInt("window.height", 800)
val windowState = rememberWindowState(
width = savedWidth.dp,
height = savedHeight.dp
)
```
---
## 9. Desktop Module Structure
```
desktopApp/
├── build.gradle.kts # Desktop-only build config
└── src/
└── jvmMain/
├── kotlin/
│ └── com/vitorpamplona/amethyst/desktop/
│ ├── Main.kt # Entry point, Window, MenuBar
│ ├── network/
│ │ ├── DesktopHttpClient.kt
│ │ └── DesktopRelayConnectionManager.kt
│ └── ui/
│ ├── FeedScreen.kt # Desktop screen layouts
│ └── LoginScreen.kt
└── resources/
├── icon.icns # macOS icon
├── icon.ico # Windows icon
└── icon.png # Linux icon
```
**Key files:**
- `Main.kt:87-138` - `application {}`, `Window`, `MenuBar`
- `Main.kt:183-264` - NavigationRail pattern
- `build.gradle.kts:45-73` - Desktop packaging config
---
## 10. Packaging & Distribution
### Build Configuration
```kotlin
// desktopApp/build.gradle.kts
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
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"))
}
windows {
iconFile.set(project.file("src/jvmMain/resources/icon.ico"))
menuGroup = "Amethyst"
}
linux {
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
}
}
}
}
```
**See:** desktopApp/build.gradle.kts:45-73
### Gradle Tasks
```bash
# Run desktop app
./gradlew :desktopApp:run
# Package for distribution
./gradlew :desktopApp:packageDmg # macOS
./gradlew :desktopApp:packageMsi # Windows
./gradlew :desktopApp:packageDeb # Linux
```
**Delegate packaging issues to gradle-expert.**
---
## Common Patterns
### Pattern: OS-Aware Shortcuts Helper
```kotlin
// commons/src/jvmMain/kotlin/shortcuts/ShortcutUtils.kt
object DesktopShortcuts {
private val isMacOS = System.getProperty("os.name")
.lowercase().contains("mac")
fun primary(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
}
fun primaryShift(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, shift = true)
} else {
KeyShortcut(key, ctrl = true, shift = true)
}
val modifierName = if (isMacOS) "Cmd" else "Ctrl"
}
// Usage in MenuBar
Item(
"New Note",
shortcut = DesktopShortcuts.primary(Key.N),
onClick = { /* ... */ }
)
```
### Pattern: Shared Composables, Platform Layouts
```kotlin
// commons/commonMain - Shared NoteCard
@Composable
fun NoteCard(note: NoteDisplayData) {
// Business logic, UI component (shared)
}
// desktopApp/jvmMain - Desktop layout
@Composable
fun FeedScreen() {
Column {
FeedHeader(/* ... */) // Shared from commons
LazyColumn {
items(notes) { note ->
NoteCard(note) // Shared composable
}
}
}
}
// amethyst/androidMain - Android layout
@Composable
fun FeedScreen() {
Scaffold(
bottomBar = { BottomNavigationBar() } // Android-specific
) {
LazyColumn {
items(notes) { note ->
NoteCard(note) // Same shared composable
}
}
}
}
```
**Philosophy:** Share UI components (cards, buttons), keep navigation/layout platform-specific.
---
## Resources
### Official Documentation
- [Desktop-only API | Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html)
- [Top-level windows management](https://kotlinlang.org/docs/multiplatform/compose-desktop-top-level-windows-management.html)
- [Tray/MenuBar Tutorial](https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Tray_Notifications_MenuBar_new/README.md)
### Bundled References
- `references/desktop-compose-apis.md` - Complete Desktop API catalog
- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
- `references/keyboard-shortcuts.md` - Standard shortcuts by OS
- `references/os-detection.md` - Platform detection patterns
### Codebase Examples
- Main.kt:87-138 - Window, MenuBar entry point
- Main.kt:183-264 - NavigationRail pattern
- FeedScreen.kt:49-136 - Desktop screen layout
- LoginScreen.kt:44-97 - Centered desktop login
---
## Questions to Ask
When working on desktop features:
1. **Should this be shared or desktop-only?**
- Business logic → Share in `commonMain`
- Navigation/layout → Keep in `desktopApp/jvmMain`
2. **Does this need OS-specific behavior?**
- Keyboard shortcuts → Yes (Cmd vs Ctrl)
- File paths → Yes (separators)
- Icons → Yes (per-OS formats)
3. **Is there a desktop UX convention?**
- Check MenuBar standards
- Consider keyboard-first design
- Tooltips for all actions
4. **Does this need gradle-expert?**
- Any `build.gradle.kts` changes → Delegate
- Packaging/distribution issues → Delegate
---
## Anti-Patterns
❌ **Hardcoding Ctrl everywhere**
```kotlin
// Main.kt:105 - Current issue
shortcut = KeyShortcut(Key.N, ctrl = true) // Wrong on macOS
```
✅ **OS-aware shortcuts**
```kotlin
shortcut = DesktopShortcuts.primary(Key.N)
```
---
❌ **Using Android navigation on Desktop**
```kotlin
Scaffold(bottomBar = { BottomNavigationBar() }) // Wrong for desktop
```
✅ **NavigationRail for desktop**
```kotlin
Row {
NavigationRail { /* ... */ }
MainContent()
}
```
---
❌ **No keyboard shortcuts**
```kotlin
IconButton(onClick = { refresh() }) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
✅ **Shortcuts + tooltips**
```kotlin
IconButton(
onClick = { refresh() },
modifier = Modifier.tooltipArea("Refresh (Cmd+R)")
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
---
## Next Steps
When implementing desktop features:
1. **Read** `references/desktop-compose-apis.md` for API catalog
2. **Check** `references/keyboard-shortcuts.md` for standard shortcuts
3. **Reference** Main.kt:87-264 for current patterns
4. **Test** on all 3 platforms (macOS, Windows, Linux) if possible
5. **Delegate** build issues to gradle-expert
6. **Share** UI components via compose-expert, not desktop-expert
---
**Version:** 1.0.0
**Last Updated:** 2025-12-30
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
@@ -0,0 +1,597 @@
# Desktop Compose APIs Catalog
Complete reference for Compose Multiplatform Desktop-only APIs.
## Window Management
### application
Root entry point for desktop apps.
```kotlin
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
Text("Hello Desktop")
}
}
```
### Window
Creates a window.
```kotlin
Window(
onCloseRequest: () -> Unit,
state: WindowState = rememberWindowState(),
visible: Boolean = true,
title: String = "Untitled",
icon: Painter? = null,
undecorated: Boolean = false,
transparent: Boolean = false,
resizable: Boolean = true,
enabled: Boolean = true,
focusable: Boolean = true,
alwaysOnTop: Boolean = false,
onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
onKeyEvent: ((KeyEvent) -> Boolean) = { false },
content: @Composable FrameWindowScope.() -> Unit
)
```
**Example:**
```kotlin
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "My App",
resizable = true
) {
// Content
}
```
### rememberWindowState
Manages window size and position.
```kotlin
@Composable
fun rememberWindowState(
placement: WindowPlacement = WindowPlacement.Floating,
isMinimized: Boolean = false,
position: WindowPosition = WindowPosition.PlatformDefault,
width: Dp = Dp.Unspecified,
height: Dp = Dp.Unspecified
): WindowState
```
**WindowPlacement:**
- `Floating` - Normal window
- `Maximized` - Fullscreen
- `Fullscreen` - Fullscreen without decorations
**WindowPosition:**
- `PlatformDefault` - OS decides
- `Aligned(alignment)` - Center, TopStart, etc.
- `Absolute(x, y)` - Fixed position in pixels
### DialogWindow
Modal dialog.
```kotlin
DialogWindow(
onCloseRequest: () -> Unit,
state: DialogState = rememberDialogState(),
visible: Boolean = true,
title: String = "Dialog",
icon: Painter? = null,
undecorated: Boolean = false,
transparent: Boolean = false,
resizable: Boolean = true,
enabled: Boolean = true,
focusable: Boolean = true,
content: @Composable DialogWindowScope.() -> Unit
)
```
**Example:**
```kotlin
var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
DialogWindow(
onCloseRequest = { showDialog = false },
title = "Confirm"
) {
Column(Modifier.padding(16.dp)) {
Text("Are you sure?")
Row {
Button(onClick = { showDialog = false }) {
Text("Cancel")
}
Button(onClick = { /* confirm */ }) {
Text("OK")
}
}
}
}
}
```
---
## MenuBar
### MenuBar
Native menu bar for windows.
```kotlin
@Composable
fun FrameWindowScope.MenuBar(
content: @Composable MenuBarScope.() -> Unit
)
```
**Example:**
```kotlin
Window(onCloseRequest = ::exitApplication) {
MenuBar {
Menu("File") {
Item("New", onClick = { /* ... */ })
Item("Open", onClick = { /* ... */ })
Separator()
Item("Quit", onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", onClick = { /* ... */ })
Item("Paste", onClick = { /* ... */ })
}
}
}
```
### Menu
Top-level menu.
```kotlin
@Composable
fun MenuBarScope.Menu(
text: String,
mnemonic: Char? = null,
enabled: Boolean = true,
content: @Composable MenuScope.() -> Unit
)
```
### Item
Menu item.
```kotlin
@Composable
fun MenuScope.Item(
text: String,
onClick: () -> Unit,
shortcut: KeyShortcut? = null,
mnemonic: Char? = null,
enabled: Boolean = true,
icon: Painter? = null
)
```
**With keyboard shortcut:**
```kotlin
Item(
text = "Save",
onClick = { save() },
shortcut = KeyShortcut(Key.S, ctrl = true),
icon = painterResource("save.png")
)
```
### Separator
Menu separator line.
```kotlin
@Composable
fun MenuScope.Separator()
```
### CheckboxItem
Toggleable menu item.
```kotlin
@Composable
fun MenuScope.CheckboxItem(
text: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
shortcut: KeyShortcut? = null,
mnemonic: Char? = null,
enabled: Boolean = true
)
```
**Example:**
```kotlin
var darkMode by remember { mutableStateOf(false) }
Menu("View") {
CheckboxItem(
text = "Dark Mode",
checked = darkMode,
onCheckedChange = { darkMode = it },
shortcut = KeyShortcut(Key.D, ctrl = true)
)
}
```
### RadioButtonItem
Radio button menu item.
```kotlin
@Composable
fun MenuScope.RadioButtonItem(
text: String,
selected: Boolean,
onClick: () -> Unit,
shortcut: KeyShortcut? = null,
mnemonic: Char? = null,
enabled: Boolean = true
)
```
---
## System Tray
### Tray
System tray icon with menu.
```kotlin
@Composable
fun ApplicationScope.Tray(
icon: Painter,
state: TrayState = rememberTrayState(),
tooltip: String? = null,
onAction: () -> Unit = {},
menu: @Composable MenuScope.() -> Unit = {}
)
```
**Example:**
```kotlin
application {
var isVisible by remember { mutableStateOf(true) }
Tray(
icon = painterResource("tray-icon.png"),
tooltip = "My App",
onAction = { isVisible = true },
menu = {
Item("Show Window", onClick = { isVisible = true })
Separator()
Item("Quit", onClick = ::exitApplication)
}
)
if (isVisible) {
Window(
onCloseRequest = { isVisible = false },
title = "App"
) {
// Content
}
}
}
```
### rememberTrayState
Manages tray state.
```kotlin
@Composable
fun rememberTrayState(): TrayState
```
---
## Notifications
### Notification (via Tray)
Show desktop notifications through tray.
```kotlin
val trayState = rememberTrayState()
Tray(
icon = painterResource("icon.png"),
state = trayState
)
// Send notification
LaunchedEffect(Unit) {
trayState.sendNotification(
Notification(
title = "Message",
message = "You have a new message",
type = Notification.Type.Info
)
)
}
```
**Notification types:**
- `Info` - Information
- `Warning` - Warning
- `Error` - Error
---
## Keyboard
### KeyShortcut
Keyboard shortcut definition.
```kotlin
data class KeyShortcut(
val key: Key,
val ctrl: Boolean = false,
val meta: Boolean = false,
val alt: Boolean = false,
val shift: Boolean = false
)
```
**Examples:**
```kotlin
// Ctrl+S (Windows/Linux)
KeyShortcut(Key.S, ctrl = true)
// Cmd+S (macOS)
KeyShortcut(Key.S, meta = true)
// Ctrl+Shift+N
KeyShortcut(Key.N, ctrl = true, shift = true)
// Alt+F4
KeyShortcut(Key.F4, alt = true)
```
### onPreviewKeyEvent / onKeyEvent
Window-level keyboard handlers.
```kotlin
Window(
onCloseRequest = ::exitApplication,
onPreviewKeyEvent = { event ->
if (event.key == Key.Escape && event.type == KeyEventType.KeyDown) {
// Handle Escape
true // Consume event
} else {
false // Propagate
}
}
) {
// Content
}
```
---
## Mouse
### PointerMoveFilter (Deprecated, use Modifier.pointerInput)
```kotlin
Box(
modifier = Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
// Handle mouse events
}
}
}
)
```
### Mouse cursor
```kotlin
Box(
modifier = Modifier.pointerHoverIcon(
icon = PointerIcon(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR))
)
) {
Text("Hover me")
}
```
**Cursor types:**
- `DEFAULT_CURSOR`
- `HAND_CURSOR`
- `TEXT_CURSOR`
- `CROSSHAIR_CURSOR`
- `WAIT_CURSOR`
- `MOVE_CURSOR`
- `E_RESIZE_CURSOR`, `W_RESIZE_CURSOR`, etc.
---
## Drag & Drop (Experimental)
### onExternalDrag
Handle drag-and-drop from external sources.
```kotlin
Box(
modifier = Modifier
.size(200.dp)
.background(Color.LightGray)
.onExternalDrag(
onDragStart = { externalDragValue ->
println("Drag started")
},
onDrag = { externalDragValue ->
println("Dragging: ${externalDragValue.dragData}")
},
onDragExit = {
println("Drag exited")
},
onDrop = { externalDragValue ->
val dragData = externalDragValue.dragData
when (dragData) {
is DragData.FilesList -> {
println("Files dropped: ${dragData.readFiles()}")
}
is DragData.Text -> {
println("Text dropped: ${dragData.readText()}")
}
else -> {}
}
}
)
) {
Text("Drop files here", Modifier.align(Alignment.Center))
}
```
---
## Resources
### painterResource
Load images from resources.
```kotlin
val icon = painterResource("icon.png")
Icon(
painter = icon,
contentDescription = "App icon"
)
```
**Resource location:** `src/jvmMain/resources/`
---
## Platform Integration
### Desktop.getDesktop() (AWT)
Access system desktop features (not Compose API, but commonly used).
```kotlin
import java.awt.Desktop
import java.net.URI
// Open URL in browser
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI("https://example.com"))
}
// Open file with default app
Desktop.getDesktop().open(File("/path/to/file.pdf"))
// Open email client
Desktop.getDesktop().mail(URI("mailto:user@example.com"))
```
### FileDialog (AWT)
File picker dialogs.
```kotlin
import java.awt.FileDialog
import java.awt.Frame
// Open file
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
fileDialog.isVisible = true
val selectedFile = fileDialog.file
val directory = fileDialog.directory
// Save file
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
saveDialog.file = "document.txt"
saveDialog.isVisible = true
```
---
## SwingPanel (Interop)
Embed Swing components in Compose.
```kotlin
import androidx.compose.ui.awt.SwingPanel
import javax.swing.JButton
SwingPanel(
factory = {
JButton("Swing Button").apply {
addActionListener {
println("Swing button clicked")
}
}
},
modifier = Modifier.size(200.dp, 50.dp)
)
```
---
## ComposePanel (Reverse Interop)
Embed Compose in Swing.
```kotlin
import androidx.compose.ui.awt.ComposePanel
import javax.swing.JFrame
val frame = JFrame("Swing Frame")
val composePanel = ComposePanel()
composePanel.setContent {
Text("Compose in Swing")
}
frame.contentPane.add(composePanel)
frame.setSize(400, 300)
frame.isVisible = true
```
---
## Version Requirements
- **Kotlin:** 2.0+
- **Compose Multiplatform:** 1.7.0+
- **JVM Target:** 11+ (recommend 21)
**See also:**
- [Official Desktop API docs](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html)
- [Compose Multiplatform repo](https://github.com/JetBrains/compose-multiplatform)
@@ -0,0 +1,464 @@
# Desktop Navigation Patterns
Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
## Core Difference
| Platform | Pattern | Location | Rationale |
|----------|---------|----------|-----------|
| **Android** | Bottom Navigation Bar | Horizontal, bottom | Thumb reach on mobile |
| **Desktop** | Navigation Rail | Vertical, left sidebar | Horizontal screen space |
---
## Desktop: NavigationRail
### Current Implementation
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:191-264`
```kotlin
@Composable
fun MainContent(
currentScreen: AppScreen,
onScreenChange: (AppScreen) -> Unit,
// ...
) {
Row(Modifier.fillMaxSize()) {
// LEFT: Vertical Sidebar (NavigationRail)
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
Spacer(Modifier.height(16.dp))
// Top navigation items
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Search, "Search") },
label = { Text("Search") },
selected = currentScreen == AppScreen.Search,
onClick = { onScreenChange(AppScreen.Search) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("DMs") },
selected = currentScreen == AppScreen.Messages,
onClick = { onScreenChange(AppScreen.Messages) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == AppScreen.Notifications,
onClick = { onScreenChange(AppScreen.Notifications) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Person, "Profile") },
label = { Text("Profile") },
selected = currentScreen == AppScreen.Profile,
onClick = { onScreenChange(AppScreen.Profile) }
)
// Push Settings to bottom
Spacer(Modifier.weight(1f))
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, "Settings") },
label = { Text("Settings") },
selected = currentScreen == AppScreen.Settings,
onClick = { onScreenChange(AppScreen.Settings) }
)
Spacer(Modifier.height(16.dp))
}
VerticalDivider()
// RIGHT: Main Content Area
Box(modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp)) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen(relayManager)
AppScreen.Search -> SearchPlaceholder()
AppScreen.Messages -> MessagesPlaceholder()
AppScreen.Notifications -> NotificationsPlaceholder()
AppScreen.Profile -> ProfileScreen(account, accountManager)
AppScreen.Settings -> RelaySettingsScreen(relayManager)
}
}
}
}
```
### Layout Structure
```
┌────────────────────────────────────────┐
│ [Menu Bar: File, Edit, View, Help] │ ← MenuBar (OS-native)
├──────┬─────────────────────────────────┤
│ │ │
│ [🏠] │ │
│ Feed │ │
│ │ │
│ [🔍] │ Main Content Area │
│Search│ (Feed, Messages, etc.) │
│ │ │
│ [✉️] │ │
│ DMs │ │
│ │ │
│ [🔔] │ │
│Alerts│ │
│ │ │
│ [👤] │ │
│Profile │
│ │ │
│ ─ │ │
│ [⚙️] │ │
│Settings │
│ │ │
└──────┴─────────────────────────────────┘
80dp Remaining width (weight=1f)
```
### Key Features
1. **Always visible:** All nav items visible at once
2. **Icon + Label:** Both shown (not just icons)
3. **Vertical list:** Natural reading order
4. **Settings at bottom:** Separated by divider + Spacer.weight(1f)
5. **80dp width:** Standard NavigationRail width
---
## Android: BottomNavigationBar (Future)
### Expected Implementation
**Location:** `amethyst/src/androidMain/kotlin/...` (not yet implemented)
```kotlin
@Composable
fun MainScreen(
currentScreen: AppScreen,
onScreenChange: (AppScreen) -> Unit
) {
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Search, "Search") },
label = { Text("Search") },
selected = currentScreen == AppScreen.Search,
onClick = { onScreenChange(AppScreen.Search) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("Messages") },
selected = currentScreen == AppScreen.Messages,
onClick = { onScreenChange(AppScreen.Messages) }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Person, "Profile") },
label = { Text("Profile") },
selected = currentScreen == AppScreen.Profile,
onClick = { onScreenChange(AppScreen.Profile) }
)
}
}
) { paddingValues ->
Box(Modifier.padding(paddingValues)) {
when (currentScreen) {
AppScreen.Feed -> FeedScreen()
AppScreen.Search -> SearchScreen()
AppScreen.Messages -> MessagesScreen()
AppScreen.Profile -> ProfileScreen()
// Settings accessed via Profile or overflow menu
}
}
}
}
```
### Layout Structure
```
┌─────────────────────────────────────┐
│ │
│ │
│ Main Content Area │
│ (Feed, Messages, etc.) │
│ │
│ │
│ │
├─────────────────────────────────────┤
│ [🏠] [🔍] [✉️] [👤] │ ← NavigationBar
│ Feed Search DMs Profile │
└─────────────────────────────────────┘
```
### Key Differences from Desktop
1. **Bottom placement:** Thumb reach
2. **Horizontal layout:** Limited vertical space
3. **Fewer items:** 3-5 primary destinations
4. **Label optional:** Can hide on small screens
5. **Settings hidden:** In profile or overflow
---
## Shared Navigation State
Both platforms use the same `AppScreen` enum from `commons`.
**File:** `commons/src/commonMain/kotlin/.../navigation/AppScreen.kt` (expected)
```kotlin
// Shared navigation destinations
enum class AppScreen {
Feed,
Search,
Messages,
Notifications,
Profile,
Settings
}
```
**State management (shared):**
```kotlin
// commons/src/jvmAndroid/kotlin/.../navigation/NavigationViewModel.kt
class NavigationViewModel : ViewModel() {
private val _currentScreen = MutableStateFlow(AppScreen.Feed)
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
fun navigateTo(screen: AppScreen) {
_currentScreen.value = screen
}
}
```
---
## Multi-Pane Desktop Layout (Advanced)
Desktop can utilize horizontal space for multi-pane layouts.
### Two-Pane Layout
```kotlin
Row(Modifier.fillMaxSize()) {
// Left: NavigationRail (fixed 80dp)
NavigationRail { /* ... */ }
VerticalDivider()
// Center: Main content (60% width)
Box(Modifier.weight(0.6f)) {
FeedScreen()
}
// Right: Detail pane (40% width, conditional)
if (selectedNote != null) {
VerticalDivider()
Box(Modifier.weight(0.4f)) {
NoteDetailPane(selectedNote)
}
}
}
```
### Layout:
```
┌──────┬───────────────────┬─────────────┐
│ │ │ │
│ Nav │ Feed List │ Detail │
│ Rail │ (60%) │ Pane │
│ │ │ (40%) │
│ │ │ │
└──────┴───────────────────┴─────────────┘
80dp weight(0.6f) weight(0.4f)
```
**Use cases:**
- Email: List + message detail
- Notes: List + editor
- Settings: Categories + options
---
## Keyboard Navigation
Desktop should support keyboard navigation.
### Tab Navigation
```kotlin
NavigationRail(
modifier = Modifier.focusable()
) {
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) },
modifier = Modifier.focusable()
)
// More items...
}
```
### Keyboard Shortcuts
```kotlin
Window(
onPreviewKeyEvent = { event ->
when {
event.key == Key.One && event.isCtrlPressed ->
onScreenChange(AppScreen.Feed).also { true }
event.key == Key.Two && event.isCtrlPressed ->
onScreenChange(AppScreen.Search).also { true }
event.key == Key.Three && event.isCtrlPressed ->
onScreenChange(AppScreen.Messages).also { true }
else -> false
}
}
) {
// Content
}
```
**Standard:**
- Ctrl+1: First nav item (Feed)
- Ctrl+2: Second nav item (Search)
- Ctrl+3: Third nav item (Messages)
- Ctrl+Comma: Settings
---
## Navigation Transitions
### Desktop (Instant)
No fancy animations. Instant switch.
```kotlin
Box {
when (currentScreen) {
AppScreen.Feed -> FeedScreen()
AppScreen.Search -> SearchScreen()
}
}
```
### Android (Animated, Future)
Can use Navigation Compose for transitions.
```kotlin
NavHost(navController, startDestination = "feed") {
composable("feed") { FeedScreen() }
composable("search") { SearchScreen() }
}
```
---
## Best Practices
### Desktop NavigationRail
✅ **DO:**
- Keep width 72-80dp
- Show both icon and label
- Use Spacer.weight(1f) for bottom items
- Separate sections with HorizontalDivider
- Limit to 5-7 primary items
❌ **DON'T:**
- Use bottom navigation on desktop
- Hide labels (plenty of space)
- Make it collapsible (not standard)
- Use hamburger menu (not desktop pattern)
### Android NavigationBar
✅ **DO:**
- Limit to 3-5 items
- Use bottom placement
- Consider label visibility on small screens
- Use standard icons
❌ **DON'T:**
- Put more than 5 items
- Use top placement (deprecated)
- Put critical actions only in nav bar
---
## Migration Strategy
When adding Android support:
1. **Extract shared state:** Move `AppScreen` to `commons/commonMain`
2. **Platform layouts:** Keep `NavigationRail` in `desktopApp/jvmMain`, `NavigationBar` in `amethyst/androidMain`
3. **Shared screens:** Composables in `commons/commonMain` (FeedScreen content)
4. **Platform chrome:** Navigation containers in platform modules
**Example:**
```kotlin
// commons/commonMain - Shared screen content
@Composable
fun FeedContent(notes: List<Note>) {
LazyColumn {
items(notes) { note ->
NoteCard(note)
}
}
}
// desktopApp/jvmMain - Desktop wrapper
@Composable
fun FeedScreen() {
Column {
FeedHeader() // Desktop-specific header
FeedContent(notes) // Shared content
}
}
// amethyst/androidMain - Android wrapper
@Composable
fun FeedScreen() {
Scaffold(
topBar = { TopAppBar { Text("Feed") } }
) {
FeedContent(notes) // Same shared content
}
}
```
---
## References
- **Current Desktop:** Main.kt:191-264
- **Material3 NavigationRail:** [Material Design Docs](https://m3.material.io/components/navigation-rail)
- **Material3 NavigationBar:** [Material Design Docs](https://m3.material.io/components/navigation-bar)
@@ -0,0 +1,400 @@
# Keyboard Shortcuts Reference
Standard keyboard shortcuts for desktop applications across macOS, Windows, and Linux.
## Primary Modifier Keys
| Platform | Primary | Secondary | Tertiary |
|----------|---------|-----------|----------|
| **macOS** | Cmd (⌘) / `meta` | Option (⌥) / `alt` | Ctrl (⌃) / `ctrl` |
| **Windows** | Ctrl / `ctrl` | Alt / `alt` | Win / `meta` |
| **Linux** | Ctrl / `ctrl` | Alt / `alt` | Super / `meta` |
**In Compose Desktop:**
```kotlin
// macOS
KeyShortcut(Key.N, meta = true) // Cmd+N
// Windows/Linux
KeyShortcut(Key.N, ctrl = true) // Ctrl+N
```
---
## File Operations
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **New** | Cmd+N | Ctrl+N | Ctrl+N | Create new |
| **Open** | Cmd+O | Ctrl+O | Ctrl+O | Open file |
| **Save** | Cmd+S | Ctrl+S | Ctrl+S | Save current |
| **Save As** | Cmd+Shift+S | Ctrl+Shift+S | Ctrl+Shift+S | Save with new name |
| **Close** | Cmd+W | Ctrl+W | Ctrl+W | Close window/tab |
| **Quit** | Cmd+Q | Ctrl+Q | Ctrl+Q | Exit app |
| **Print** | Cmd+P | Ctrl+P | Ctrl+P | Print |
**Compose Implementation:**
```kotlin
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
MenuBar {
Menu("File") {
Item(
"New Note",
shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true)
} else {
KeyShortcut(Key.N, ctrl = true)
},
onClick = { createNewNote() }
)
Item(
"Save",
shortcut = if (isMacOS) {
KeyShortcut(Key.S, meta = true)
} else {
KeyShortcut(Key.S, ctrl = true)
},
onClick = { save() }
)
Separator()
Item(
"Quit",
shortcut = if (isMacOS) {
KeyShortcut(Key.Q, meta = true)
} else {
KeyShortcut(Key.Q, ctrl = true)
},
onClick = ::exitApplication
)
}
}
```
---
## Edit Operations
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **Undo** | Cmd+Z | Ctrl+Z | Ctrl+Z | Universal |
| **Redo** | Cmd+Shift+Z | Ctrl+Y | Ctrl+Y | Windows/Linux use Y |
| **Cut** | Cmd+X | Ctrl+X | Ctrl+X | Universal |
| **Copy** | Cmd+C | Ctrl+C | Ctrl+C | Universal |
| **Paste** | Cmd+V | Ctrl+V | Ctrl+V | Universal |
| **Select All** | Cmd+A | Ctrl+A | Ctrl+A | Universal |
| **Find** | Cmd+F | Ctrl+F | Ctrl+F | Search |
| **Find Next** | Cmd+G | F3 | F3 | Next result |
| **Replace** | Cmd+Option+F | Ctrl+H | Ctrl+H | Find & replace |
**Note:** Undo/Redo typically handled by text fields automatically.
---
## Navigation
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **Tab 1** | Cmd+1 | Ctrl+1 | Ctrl+1 | First tab/view |
| **Tab 2** | Cmd+2 | Ctrl+2 | Ctrl+2 | Second tab/view |
| **Tab 3** | Cmd+3 | Ctrl+3 | Ctrl+3 | Third tab/view |
| **Next Tab** | Cmd+Option+→ | Ctrl+Tab | Ctrl+Tab | Cycle forward |
| **Prev Tab** | Cmd+Option+← | Ctrl+Shift+Tab | Ctrl+Shift+Tab | Cycle back |
| **Go Back** | Cmd+[ | Alt+← | Alt+← | Browser-style |
| **Go Forward** | Cmd+] | Alt+→ | Alt+→ | Browser-style |
**Compose Implementation:**
```kotlin
Window(
onPreviewKeyEvent = { event ->
if (event.type == KeyEventType.KeyDown) {
when {
event.key == Key.One && event.isPrimaryPressed() -> {
navigateTo(AppScreen.Feed)
true
}
event.key == Key.Two && event.isPrimaryPressed() -> {
navigateTo(AppScreen.Search)
true
}
event.key == Key.Three && event.isPrimaryPressed() -> {
navigateTo(AppScreen.Messages)
true
}
else -> false
}
} else false
}
) {
// Content
}
// Helper extension
fun KeyEvent.isPrimaryPressed() = if (isMacOS) isMetaPressed else isCtrlPressed
```
---
## Window Management
| Action | macOS | Windows | Linux | Notes |
|--------|-------|---------|-------|-------|
| **New Window** | Cmd+N | Ctrl+N | Ctrl+N | New instance |
| **Close Window** | Cmd+W | Alt+F4 | Alt+F4 | Close current |
| **Minimize** | Cmd+M | Win+Down | Super+Down | Minimize to dock/taskbar |
| **Maximize** | Cmd+Ctrl+F | Win+Up | Super+Up | Fullscreen/maximize |
| **Hide App** | Cmd+H | - | - | macOS only |
| **Switch Window** | Cmd+` | Alt+Tab | Alt+Tab | Between app windows |
**Note:** Window management often handled by OS, not app shortcuts.
---
## App-Specific (Amethyst)
### Nostr Actions
| Action | macOS | Windows | Linux | Description |
|--------|-------|---------|-------|-------------|
| **New Note** | Cmd+N | Ctrl+N | Ctrl+N | Compose new post |
| **Refresh Feed** | Cmd+R | Ctrl+R | Ctrl+R | Reload timeline |
| **Search** | Cmd+K | Ctrl+K | Ctrl+K | Quick search |
| **DMs** | Cmd+Shift+M | Ctrl+Shift+M | Ctrl+Shift+M | Open messages |
| **Settings** | Cmd+, | Ctrl+, | Ctrl+, | Open preferences |
| **Notifications** | Cmd+Shift+N | Ctrl+Shift+N | Ctrl+Shift+N | View alerts |
**Implementation:**
```kotlin
MenuBar {
Menu("File") {
Item(
"New Note",
shortcut = DesktopShortcuts.primary(Key.N),
onClick = { showComposeDialog() }
)
Item(
"Settings",
shortcut = DesktopShortcuts.primary(Key.Comma),
onClick = { navigateTo(AppScreen.Settings) }
)
}
Menu("View") {
Item(
"Refresh Feed",
shortcut = DesktopShortcuts.primary(Key.R),
onClick = { refreshFeed() }
)
Item(
"Search",
shortcut = DesktopShortcuts.primary(Key.K),
onClick = { focusSearch() }
)
}
}
```
---
## Accessibility
| Action | macOS | Windows | Linux | Description |
|--------|-------|---------|-------|-------------|
| **Zoom In** | Cmd++ | Ctrl++ | Ctrl++ | Increase size |
| **Zoom Out** | Cmd+- | Ctrl+- | Ctrl+- | Decrease size |
| **Reset Zoom** | Cmd+0 | Ctrl+0 | Ctrl+0 | Default size |
| **Help** | Cmd+? | F1 | F1 | Show help |
---
## Best Practices
### 1. OS-Aware Helper
Create a utility for OS detection:
```kotlin
// commons/src/jvmMain/kotlin/utils/PlatformShortcuts.kt
object DesktopShortcuts {
private val isMacOS = System.getProperty("os.name")
.lowercase()
.contains("mac")
fun primary(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
}
fun primaryShift(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, shift = true)
} else {
KeyShortcut(key, ctrl = true, shift = true)
}
fun primaryAlt(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, alt = true)
} else {
KeyShortcut(key, ctrl = true, alt = true)
}
val modifierName = if (isMacOS) "Cmd" else "Ctrl"
val secondaryName = if (isMacOS) "Option" else "Alt"
}
```
**Usage:**
```kotlin
Item(
"Save",
shortcut = DesktopShortcuts.primary(Key.S),
onClick = { save() }
)
```
### 2. Show Shortcuts in Tooltips
```kotlin
IconButton(
onClick = { refresh() },
modifier = Modifier.tooltipArea {
Text("Refresh (${DesktopShortcuts.modifierName}+R)")
}
) {
Icon(Icons.Default.Refresh, "Refresh")
}
```
### 3. Shortcuts Menu
Provide a "Keyboard Shortcuts" help menu:
```kotlin
Menu("Help") {
Item("Keyboard Shortcuts", onClick = { showShortcutsDialog() })
}
// Dialog content
@Composable
fun ShortcutsDialog() {
Dialog(onDismissRequest = { /* close */ }) {
Surface {
Column(Modifier.padding(16.dp)) {
Text("Keyboard Shortcuts", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
ShortcutRow("New Note", "${DesktopShortcuts.modifierName}+N")
ShortcutRow("Save", "${DesktopShortcuts.modifierName}+S")
ShortcutRow("Search", "${DesktopShortcuts.modifierName}+K")
ShortcutRow("Settings", "${DesktopShortcuts.modifierName}+,")
// ...
}
}
}
}
@Composable
fun ShortcutRow(action: String, shortcut: String) {
Row(
Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(action, style = MaterialTheme.typography.bodyMedium)
Text(
shortcut,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
```
### 4. Avoid Conflicts
**Check for OS-level shortcuts:**
| macOS Reserved | Description |
|----------------|-------------|
| Cmd+Tab | Switch apps |
| Cmd+Space | Spotlight |
| Cmd+H | Hide window |
| Cmd+M | Minimize |
| Cmd+Q | Quit |
| Cmd+W | Close window |
**Windows Reserved:**
| Windows Reserved | Description |
|-----------------|-------------|
| Win+D | Show desktop |
| Win+E | File Explorer |
| Win+L | Lock screen |
| Alt+Tab | Switch apps |
| Alt+F4 | Close window |
**Don't override these unless critical.**
---
## Testing Shortcuts
```kotlin
// Test OS detection
@Test
fun testOsDetection() {
val osName = System.getProperty("os.name")
println("OS: $osName")
val isMacOS = osName.lowercase().contains("mac")
println("Is macOS: $isMacOS")
val shortcut = if (isMacOS) {
KeyShortcut(Key.N, meta = true)
} else {
KeyShortcut(Key.N, ctrl = true)
}
println("Primary modifier for New: $shortcut")
}
```
---
## Current Issues in Amethyst
**Main.kt:105-123** hardcodes `ctrl = true`:
```kotlin
// ❌ WRONG: Hardcoded Ctrl (doesn't work on macOS)
Item(
"New Note",
shortcut = KeyShortcut(Key.N, ctrl = true), // Should be Cmd on macOS
onClick = { /* ... */ }
)
```
**Fix:**
```kotlin
// ✅ CORRECT: OS-aware
Item(
"New Note",
shortcut = DesktopShortcuts.primary(Key.N),
onClick = { /* ... */ }
)
```
---
## References
- [macOS Keyboard Shortcuts](https://support.apple.com/en-us/102650)
- [Windows Keyboard Shortcuts](https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec)
- [GNOME Keyboard Shortcuts](https://help.gnome.org/users/gnome-help/stable/shell-keyboard-shortcuts.html)
- [Material Design: Keyboard Shortcuts](https://m3.material.io/foundations/interaction/keyboard)
- [Compose Desktop: Keyboard Events](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-keyboard.html)
@@ -0,0 +1,579 @@
# OS Detection & Platform-Specific Code
Patterns for detecting operating system and implementing platform-specific behavior in Compose Desktop.
## OS Detection
### Basic Detection
```kotlin
val osName = System.getProperty("os.name").lowercase()
val isMacOS = osName.contains("mac")
val isWindows = osName.contains("win")
val isLinux = osName.contains("nux") || osName.contains("nix")
```
### System Properties
```kotlin
// OS name
System.getProperty("os.name")
// Examples: "Mac OS X", "Windows 10", "Linux"
// OS version
System.getProperty("os.version")
// Examples: "14.2.1", "10.0", "6.5.0-14-generic"
// OS architecture
System.getProperty("os.arch")
// Examples: "aarch64", "x86_64", "amd64"
// User home directory
System.getProperty("user.home")
// Examples: "/Users/username", "C:\Users\username", "/home/username"
// File separator
System.getProperty("file.separator")
// Examples: "/" (Unix), "\" (Windows)
// Path separator
System.getProperty("path.separator")
// Examples: ":" (Unix), ";" (Windows)
```
---
## PlatformDetector Utility
Create a centralized utility for platform detection.
**File:** `commons/src/jvmMain/kotlin/utils/PlatformDetector.kt`
```kotlin
package com.vitorpamplona.amethyst.commons.utils
object PlatformDetector {
private val osName = System.getProperty("os.name").lowercase()
val isMacOS: Boolean = osName.contains("mac")
val isWindows: Boolean = osName.contains("win")
val isLinux: Boolean = osName.contains("nux") || osName.contains("nix")
val platform: Platform = when {
isMacOS -> Platform.MacOS
isWindows -> Platform.Windows
isLinux -> Platform.Linux
else -> Platform.Unknown
}
enum class Platform {
MacOS,
Windows,
Linux,
Unknown
}
// File paths
val fileSeparator: String = System.getProperty("file.separator")
val pathSeparator: String = System.getProperty("path.separator")
// User directories
val userHome: String = System.getProperty("user.home")
val appDataDir: String = when (platform) {
Platform.MacOS -> "$userHome/Library/Application Support"
Platform.Windows -> System.getenv("APPDATA") ?: "$userHome\\AppData\\Roaming"
Platform.Linux -> System.getenv("XDG_CONFIG_HOME") ?: "$userHome/.config"
Platform.Unknown -> userHome
}
// Modifier key names
val primaryModifierName: String = if (isMacOS) "Cmd" else "Ctrl"
val secondaryModifierName: String = if (isMacOS) "Option" else "Alt"
fun platformSpecific(
macOS: () -> Unit = {},
windows: () -> Unit = {},
linux: () -> Unit = {},
fallback: () -> Unit = {}
) {
when (platform) {
Platform.MacOS -> macOS()
Platform.Windows -> windows()
Platform.Linux -> linux()
Platform.Unknown -> fallback()
}
}
}
```
**Usage:**
```kotlin
// Simple check
if (PlatformDetector.isMacOS) {
// macOS-specific code
}
// Pattern matching
when (PlatformDetector.platform) {
Platform.MacOS -> setupMacDock()
Platform.Windows -> setupWindowsTray()
Platform.Linux -> setupLinuxTray()
Platform.Unknown -> showWarning()
}
// Platform-specific execution
PlatformDetector.platformSpecific(
macOS = { setupMacMenuBar() },
windows = { setupWindowsMenu() },
linux = { setupLinuxMenu() }
)
// File paths
val configPath = "${PlatformDetector.appDataDir}${PlatformDetector.fileSeparator}amethyst"
```
---
## Platform-Specific UI
### Keyboard Shortcuts Helper
```kotlin
package com.vitorpamplona.amethyst.commons.utils
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
object DesktopShortcuts {
private val isMacOS = PlatformDetector.isMacOS
fun primary(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true)
} else {
KeyShortcut(key, ctrl = true)
}
fun primaryShift(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, shift = true)
} else {
KeyShortcut(key, ctrl = true, shift = true)
}
fun primaryAlt(key: Key) = if (isMacOS) {
KeyShortcut(key, meta = true, alt = true)
} else {
KeyShortcut(key, ctrl = true, alt = true)
}
val modifierName = PlatformDetector.primaryModifierName
val secondaryName = PlatformDetector.secondaryModifierName
fun formatShortcut(key: String, withPrimary: Boolean = true): String {
return if (withPrimary) "$modifierName+$key" else key
}
}
```
### File Paths Helper
```kotlin
package com.vitorpamplona.amethyst.commons.utils
import java.io.File
object FilePaths {
private val separator = PlatformDetector.fileSeparator
fun join(vararg parts: String): String {
return parts.joinToString(separator)
}
fun appConfig(appName: String): String {
return join(PlatformDetector.appDataDir, appName)
}
fun appCache(appName: String): String {
return when (PlatformDetector.platform) {
PlatformDetector.Platform.MacOS ->
join(PlatformDetector.userHome, "Library", "Caches", appName)
PlatformDetector.Platform.Windows ->
join(System.getenv("LOCALAPPDATA") ?: "${PlatformDetector.userHome}\\AppData\\Local", appName)
PlatformDetector.Platform.Linux ->
join(System.getenv("XDG_CACHE_HOME") ?: "${PlatformDetector.userHome}/.cache", appName)
else -> join(PlatformDetector.userHome, ".cache", appName)
}
}
fun ensureDirectory(path: String): File {
return File(path).apply {
if (!exists()) {
mkdirs()
}
}
}
}
// Usage
val configDir = FilePaths.ensureDirectory(FilePaths.appConfig("amethyst"))
val cacheDir = FilePaths.ensureDirectory(FilePaths.appCache("amethyst"))
```
---
## Platform-Specific Features
### Open External URL
```kotlin
// commons/src/commonMain/kotlin/utils/ExternalUrl.kt
expect fun openExternalUrl(url: String)
// commons/src/jvmMain/kotlin/utils/ExternalUrl.jvm.kt
import java.awt.Desktop
import java.net.URI
actual fun openExternalUrl(url: String) {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(URI(url))
}
}
```
### File Picker
```kotlin
// Platform-specific file picker
fun showFilePicker(
title: String = "Select file",
mode: FilePickerMode = FilePickerMode.Load
): String? {
val fileDialog = java.awt.FileDialog(
java.awt.Frame(),
title,
when (mode) {
FilePickerMode.Load -> java.awt.FileDialog.LOAD
FilePickerMode.Save -> java.awt.FileDialog.SAVE
}
)
// macOS-specific: Enable file selection features
if (PlatformDetector.isMacOS) {
System.setProperty("apple.awt.fileDialogForDirectories", "false")
}
fileDialog.isVisible = true
return fileDialog.file?.let { "${fileDialog.directory}$it" }
}
enum class FilePickerMode {
Load,
Save
}
```
### Directory Picker (macOS)
```kotlin
fun showDirectoryPicker(title: String = "Select directory"): String? {
if (PlatformDetector.isMacOS) {
// macOS-specific directory picker
System.setProperty("apple.awt.fileDialogForDirectories", "true")
}
val fileDialog = java.awt.FileDialog(java.awt.Frame(), title, java.awt.FileDialog.LOAD)
fileDialog.isVisible = true
if (PlatformDetector.isMacOS) {
System.setProperty("apple.awt.fileDialogForDirectories", "false")
}
return fileDialog.directory
}
```
---
## Window Decorations
### macOS-Specific
```kotlin
// Unified title bar (macOS Big Sur+)
if (PlatformDetector.isMacOS) {
Window(
undecorated = false,
transparent = true,
// ...
) {
// Custom title bar
}
}
```
### Windows-Specific
```kotlin
// Custom window chrome (Windows)
if (PlatformDetector.isWindows) {
Window(
undecorated = true,
// Custom decorations
) {
Column {
// Custom title bar with min/max/close buttons
WindowTitleBar()
// Content
}
}
}
```
---
## System Tray Icons
Different icon formats per OS:
```kotlin
fun getTrayIcon(): Painter {
return when (PlatformDetector.platform) {
Platform.MacOS -> painterResource("tray-icon-mac.png") // Template icon
Platform.Windows -> painterResource("tray-icon-win.ico")
Platform.Linux -> painterResource("tray-icon-linux.png")
else -> painterResource("tray-icon.png")
}
}
// macOS: Template icons (black/transparent)
// Windows: ICO format, 16x16
// Linux: PNG, typically 24x24
```
---
## Native Notifications
```kotlin
// Platform-specific notification implementation
fun sendNotification(title: String, message: String) {
PlatformDetector.platformSpecific(
macOS = {
// macOS: Use NSUserNotification (via tray)
trayState.sendNotification(
Notification(title, message, Notification.Type.Info)
)
},
windows = {
// Windows: Use Windows toast notifications
trayState.sendNotification(
Notification(title, message, Notification.Type.Info)
)
},
linux = {
// Linux: Use libnotify (via tray)
trayState.sendNotification(
Notification(title, message, Notification.Type.Info)
)
}
)
}
```
---
## Architecture Detection
```kotlin
object ArchDetector {
private val arch = System.getProperty("os.arch").lowercase()
val isArm: Boolean = arch.contains("aarch") || arch.contains("arm")
val isX64: Boolean = arch.contains("x86_64") || arch.contains("amd64")
val isX86: Boolean = arch.contains("x86") && !isX64
val architecture: Architecture = when {
isArm -> Architecture.ARM
isX64 -> Architecture.X64
isX86 -> Architecture.X86
else -> Architecture.Unknown
}
enum class Architecture {
ARM,
X64,
X86,
Unknown
}
}
// Usage: Load correct native library
fun loadNativeLib() {
val libName = when {
PlatformDetector.isMacOS && ArchDetector.isArm -> "libsecp256k1-macos-arm64"
PlatformDetector.isMacOS && ArchDetector.isX64 -> "libsecp256k1-macos-x64"
PlatformDetector.isWindows && ArchDetector.isX64 -> "libsecp256k1-win-x64"
PlatformDetector.isLinux && ArchDetector.isX64 -> "libsecp256k1-linux-x64"
else -> throw UnsupportedOperationException("Unsupported platform")
}
System.loadLibrary(libName)
}
```
---
## Testing Platform Detection
```kotlin
@Test
fun testPlatformDetection() {
println("OS: ${System.getProperty("os.name")}")
println("Version: ${System.getProperty("os.version")}")
println("Arch: ${System.getProperty("os.arch")}")
println()
println("Is macOS: ${PlatformDetector.isMacOS}")
println("Is Windows: ${PlatformDetector.isWindows}")
println("Is Linux: ${PlatformDetector.isLinux}")
println("Platform: ${PlatformDetector.platform}")
println()
println("User home: ${PlatformDetector.userHome}")
println("App data: ${PlatformDetector.appDataDir}")
println("File separator: ${PlatformDetector.fileSeparator}")
}
// Example output (macOS):
// OS: Mac OS X
// Version: 14.2.1
// Arch: aarch64
//
// Is macOS: true
// Is Windows: false
// Is Linux: false
// Platform: MacOS
//
// User home: /Users/username
// App data: /Users/username/Library/Application Support
// File separator: /
```
---
## Best Practices
### 1. Centralize Detection
**DO:** Use PlatformDetector singleton
```kotlin
if (PlatformDetector.isMacOS) { /* ... */ }
```
**DON'T:** Repeat detection everywhere
```kotlin
if (System.getProperty("os.name").lowercase().contains("mac")) { /* ... */ }
```
### 2. Use expect/actual for Platform APIs
```kotlin
// commonMain
expect fun openFile(path: String)
// jvmMain (Desktop)
actual fun openFile(path: String) {
Desktop.getDesktop().open(File(path))
}
// androidMain
actual fun openFile(path: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(path)))
}
```
### 3. Graceful Degradation
```kotlin
fun openBrowser(url: String) {
try {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().browse(URI(url))
} else {
// Fallback: Copy to clipboard
Toolkit.getDefaultToolkit().systemClipboard.setContents(
StringSelection(url),
null
)
showMessage("URL copied to clipboard: $url")
}
} catch (e: Exception) {
showError("Failed to open browser: ${e.message}")
}
}
```
### 4. Test on All Platforms
Always test platform-specific code on:
- macOS (Intel + Apple Silicon if possible)
- Windows (10/11)
- Linux (Ubuntu/Fedora)
---
## Common Patterns
### Pattern: Config File Location
```kotlin
fun getConfigFile(filename: String): File {
val configDir = when (PlatformDetector.platform) {
Platform.MacOS ->
File("${PlatformDetector.userHome}/Library/Application Support/Amethyst")
Platform.Windows ->
File("${System.getenv("APPDATA")}\\Amethyst")
Platform.Linux ->
File("${PlatformDetector.userHome}/.config/amethyst")
else ->
File("${PlatformDetector.userHome}/.amethyst")
}
if (!configDir.exists()) {
configDir.mkdirs()
}
return File(configDir, filename)
}
// Usage
val settingsFile = getConfigFile("settings.json")
```
### Pattern: Platform-Specific Resources
```kotlin
fun getPlatformIcon(name: String): Painter {
val extension = when (PlatformDetector.platform) {
Platform.MacOS -> "icns"
Platform.Windows -> "ico"
else -> "png"
}
return painterResource("$name.$extension")
}
// Resources:
// src/jvmMain/resources/app-icon.icns (macOS)
// src/jvmMain/resources/app-icon.ico (Windows)
// src/jvmMain/resources/app-icon.png (Linux)
```
---
## References
- [System Properties (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/System.html#getProperties())
- [Desktop API (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/awt/Desktop.html)
- [File System Standards (XDG)](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)
+811
View File
@@ -0,0 +1,811 @@
---
name: kotlin-expert
description: Advanced Kotlin patterns for AmethystMultiplatform. Flow state management (StateFlow/SharedFlow), sealed hierarchies (classes vs interfaces), immutability (@Immutable, data classes), DSL builders (type-safe fluent APIs), inline functions (reified generics, performance). Use when working with: (1) State management patterns (StateFlow/SharedFlow/MutableStateFlow), (2) Sealed classes or sealed interfaces, (3) @Immutable annotations for Compose, (4) DSL builders with lambda receivers, (5) inline/reified functions, (6) Kotlin performance optimization. Complements kotlin-coroutines agent (async patterns) - this skill focuses on Amethyst-specific Kotlin idioms.
---
# Kotlin Expert
Advanced Kotlin patterns for AmethystMultiplatform. Covers Flow state management, sealed hierarchies, immutability, DSL builders, and inline functions with real codebase examples.
## Mental Model
**Kotlin in Amethyst:**
```
State Management (Hot Flows)
├── StateFlow<T> # Single value, always has value, replays to new subscribers
├── SharedFlow<T> # Event stream, configurable replay, multiple subscribers
└── MutableStateFlow<T> # Private mutable, public via .asStateFlow()
Type Safety (Sealed Hierarchies)
├── sealed class # State variants with data (AccountState.LoggedIn/LoggedOut)
└── sealed interface # Generic result types (SignerResult<T>)
Compose Performance (@Immutable)
├── @Immutable # 173+ event classes - prevents recomposition
└── data class # Structural equality, copy(), immutable by convention
DSL Patterns
├── Builder classes # Fluent APIs (TagArrayBuilder)
├── Lambda receivers # inline fun tagArray { ... }
└── Method chaining # return this
Performance
├── inline fun # Eliminate lambda overhead
├── reified type params # Runtime type info (OptimizedJsonMapper)
└── value class # Zero-cost wrappers (NOT USED yet in Amethyst)
```
**Delegation:**
- **kotlin-coroutines agent**: Deep async (structured concurrency, channels, operators)
- **kotlin-multiplatform skill**: expect/actual, source sets
- **This skill**: Amethyst Kotlin idioms, state patterns, type safety
---
## 1. Flow State Management
### StateFlow: State that Changes
**Mental model:** StateFlow is a "hot" observable state holder. Always has a value, new collectors immediately get current state.
**Amethyst pattern:**
```kotlin
// AccountManager.kt:48-50
class AccountManager {
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
fun login(key: String) {
_accountState.value = AccountState.LoggedIn(...)
}
}
```
**Key principles:**
1. **Private mutable, public immutable**: `_accountState` (MutableStateFlow) private, `accountState` (StateFlow) public
2. **Always has value**: Initial value required (`LoggedOut`)
3. **Single value**: Replays ONE most recent value to new subscribers
4. **Hot**: Stays in memory, all collectors share same instance
**See:** AccountManager.kt:48-50, RelayConnectionManager.kt:49-52
### SharedFlow: Event Streams
**Mental model:** SharedFlow is a "hot" broadcast stream for events. Configurable replay buffer, doesn't require initial value.
**Amethyst pattern:**
```kotlin
// RelayConnectionManager.kt:52-53
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
```
**When to use StateFlow vs SharedFlow:**
| Scenario | Use StateFlow | Use SharedFlow |
|----------|---------------|----------------|
| **UI state** | ✅ Current screen data, login status | ❌ |
| **One-time events** | ❌ | ✅ Navigation, snackbars, toasts |
| **Always has value** | ✅ | ❌ Optional |
| **Replay count** | 1 (latest only) | Configurable (0, 1, n) |
| **Backpressure** | Conflates (drops old) | Configurable buffer |
**Best practice:**
```kotlin
// State: Use StateFlow
private val _uiState = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// Events: Use SharedFlow
private val _navigationEvents = MutableSharedFlow<NavEvent>(replay = 0)
val navigationEvents: SharedFlow<NavEvent> = _navigationEvents.asSharedFlow()
```
### Flow Anti-Patterns
❌ **Exposing mutable state:**
```kotlin
val accountState: MutableStateFlow<AccountState> // BAD: Can be mutated externally
```
✅ **Expose immutable:**
```kotlin
val accountState: StateFlow<AccountState> = _accountState.asStateFlow() // GOOD
```
---
❌ **SharedFlow for state:**
```kotlin
val loginState = MutableSharedFlow<LoginState>() // BAD: State might get lost
```
✅ **StateFlow for state:**
```kotlin
val loginState = MutableStateFlow(LoginState.LoggedOut) // GOOD: Always has value
```
**See:** `references/flow-patterns.md` for comprehensive examples.
---
## 2. Sealed Hierarchies
### Sealed Classes: State Variants
**Mental model:** Sealed classes represent a closed set of variants that share common data/behavior.
**Amethyst pattern:**
```kotlin
// AccountManager.kt:36-46
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean
) : AccountState()
}
// Usage
when (state) {
is AccountState.LoggedOut -> showLogin()
is AccountState.LoggedIn -> showFeed(state.pubKeyHex)
} // Exhaustive - compiler enforces all cases
```
**Key principles:**
1. **Closed hierarchy**: All subclasses known at compile-time
2. **Exhaustive when**: Compiler ensures all cases handled
3. **Shared data**: Sealed class can hold common properties
4. **Single inheritance**: Subclass can't extend another class
**When to use:**
- Modeling UI states (Loading, Success, Error)
- Login states (LoggedOut, LoggedIn)
- Result types with different data per variant
### Sealed Interfaces: Generic Result Types
**Mental model:** Sealed interfaces for contracts with multiple implementations that need generics or multiple inheritance.
**Amethyst pattern:**
```kotlin
// SignerResult.kt:25-46
sealed interface SignerResult<T : IResult> {
sealed interface RequestAddressed<T : IResult> : SignerResult<T> {
class Successful<T : IResult>(val result: T) : RequestAddressed<T>
class Rejected<T : IResult> : RequestAddressed<T>
class TimedOut<T : IResult> : RequestAddressed<T>
class ReceivedButCouldNotPerform<T : IResult>(
val message: String?
) : RequestAddressed<T>
}
}
// Usage with generics
fun handleResult(result: SignerResult<SignResult>) {
when (result) {
is SignerResult.RequestAddressed.Successful -> processEvent(result.result.event)
is SignerResult.RequestAddressed.Rejected -> showRejected()
is SignerResult.RequestAddressed.TimedOut -> showTimeout()
}
}
```
**Key principles:**
1. **Multiple inheritance**: Subtype can implement other interfaces
2. **Variance**: Supports `out`/`in` modifiers for generics
3. **No constructor**: Can't hold state directly (subtypes can)
4. **Nested hierarchies**: Can create sub-sealed hierarchies
### Sealed Class vs Sealed Interface
| Feature | Sealed Class | Sealed Interface |
|---------|--------------|------------------|
| **Constructor** | ✅ Can hold common state | ❌ No constructor |
| **Inheritance** | ❌ Single parent only | ✅ Multiple interfaces |
| **Generics** | ❌ No variance | ✅ Covariance/contravariance |
| **Use case** | State variants | Result types, contracts |
**Decision tree:**
```
Need to hold common data in base?
YES → sealed class
NO → sealed interface
Need generics with variance (out/in)?
YES → sealed interface
NO → Either works
Subtypes need multiple inheritance?
YES → sealed interface
NO → Either works
```
**Amethyst examples:**
- `sealed class AccountState` - state variants with different data
- `sealed interface SignerResult<T>` - generic result types with variance
**See:** `references/sealed-class-catalog.md` for all sealed types in quartz.
---
## 3. Immutability & Compose Performance
### @Immutable Annotation
**Mental model:** @Immutable tells Compose "this value never changes after construction." Compose can skip recomposition if @Immutable object reference doesn't change.
**Amethyst pattern:**
```kotlin
// TextNoteEvent.kt:51-63
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
// All properties immutable (val), no mutable state
}
```
**Key principles:**
1. **All properties immutable**: Only `val`, never `var`
2. **No mutable collections**: Use `ImmutableList`, `Array`, not `MutableList`
3. **Deep immutability**: Nested objects also immutable
4. **Compose optimization**: Skips recomposition if reference equals
**Why it matters:**
```kotlin
// Without @Immutable
@Composable
fun NoteCard(note: TextNoteEvent) { // Recomposes every time parent recomposes
Text(note.content)
}
// With @Immutable
@Composable
fun NoteCard(note: TextNoteEvent) { // Only recomposes if note reference changes
Text(note.content)
}
```
**173+ @Immutable classes** in quartz - all events immutable for Compose performance.
### Data Classes & Immutability
**Pattern:**
```kotlin
@Immutable
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null
) {
// Implicit: equals(), hashCode(), copy(), toString()
}
// Usage
val oldStatus = RelayStatus(url, connected = false)
val newStatus = oldStatus.copy(connected = true) // Immutable update
```
**Key principles:**
1. **Structural equality**: `equals()` compares properties, not reference
2. **copy()**: Create modified copies without mutating
3. **All properties in constructor**: For proper `equals()`/`hashCode()`
4. **Prefer val**: Make properties immutable
### kotlinx.collections.immutable
**Pattern:**
```kotlin
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
// Instead of List (which could be mutable internally)
val relays: ImmutableList<String> = persistentListOf("wss://relay1.com", "wss://relay2.com")
// Add returns new instance
val updated = relays.add("wss://relay3.com") // relays unchanged, updated has 3 items
```
**When to use:**
- Compose state that needs collection
- Publicly exposed collections
- Shared state across threads
**See:** `references/immutability-patterns.md`
---
## 4. DSL Builders
### Type-Safe Fluent APIs
**Mental model:** DSL (Domain-Specific Language) builders use lambda receivers and method chaining to create readable, type-safe APIs.
**Amethyst pattern:**
```kotlin
// TagArrayBuilder.kt:23-90
class TagArrayBuilder<T : IEvent> {
private val tagList = mutableMapOf<String, MutableList<Tag>>()
fun add(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList.getOrPut(tag[0], ::mutableListOf).add(tag)
return this // Method chaining
}
fun remove(tagName: String): TagArrayBuilder<T> {
tagList.remove(tagName)
return this // Method chaining
}
fun build() = tagList.flatMap { it.value }.toTypedArray()
}
// Inline function with lambda receiver (line 90)
inline fun <T : Event> tagArray(initializer: TagArrayBuilder<T>.() -> Unit = {}): TagArray =
TagArrayBuilder<T>().apply(initializer).build()
```
**Usage:**
```kotlin
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", eventId, relay, "reply"))
add(arrayOf("p", pubkey))
remove("a") // Remove address tags
}
```
**Key patterns:**
1. **Method chaining**: Return `this` from mutator methods
2. **Lambda receiver**: `TagArrayBuilder<T>.() -> Unit` - lambda has `this: TagArrayBuilder<T>`
3. **inline function**: Eliminates lambda overhead
4. **apply()**: Executes lambda with receiver, returns receiver
### DSL Pattern Template
```kotlin
class MyBuilder {
private val items = mutableListOf<Item>()
fun add(item: Item): MyBuilder {
items.add(item)
return this
}
fun build(): Result = Result(items.toList())
}
inline fun myDsl(init: MyBuilder.() -> Unit): Result =
MyBuilder().apply(init).build()
// Usage
val result = myDsl {
add(Item("foo"))
add(Item("bar"))
}
```
**Why inline?**
- Eliminates lambda object allocation
- Enables `reified` type parameters
- Better performance for frequently-called DSLs
**See:** `references/dsl-builder-examples.md` for more patterns.
---
## 5. Inline Functions & reified
### inline fun: Eliminate Overhead
**Mental model:** `inline` copies function body to call site. No lambda object created, direct code insertion.
**Pattern:**
```kotlin
// Without inline
fun <T> measureTime(block: () -> T): T {
val start = System.currentTimeMillis()
val result = block() // Lambda object allocated
println("Time: ${System.currentTimeMillis() - start}ms")
return result
}
// With inline
inline fun <T> measureTime(block: () -> T): T {
val start = System.currentTimeMillis()
val result = block() // No allocation, code inlined
println("Time: ${System.currentTimeMillis() - start}ms")
return result
}
```
**Benefits:**
1. **Zero overhead**: No lambda object allocation
2. **Non-local returns**: Can `return` from outer function inside lambda
3. **reified enabled**: Access to type parameter at runtime
### reified: Runtime Type Access
**Mental model:** `reified` makes generic type `T` available at runtime. Only works with `inline`.
**Amethyst pattern:**
```kotlin
// OptimizedJsonMapper.kt:48
expect object OptimizedJsonMapper {
inline fun <reified T : OptimizedSerializable> fromJsonTo(json: String): T
}
// Usage
val event: TextNoteEvent = OptimizedJsonMapper.fromJsonTo(jsonString)
// Compiler inlines and passes TextNoteEvent::class info
```
**Without reified:**
```kotlin
// Would need to pass class explicitly
fun <T> fromJson(json: String, clazz: KClass<T>): T {
return when (clazz) {
TextNoteEvent::class -> parseTextNote(json) as T
// ...
}
}
val event = fromJson(json, TextNoteEvent::class) // Verbose
```
**With reified:**
```kotlin
inline fun <reified T> fromJson(json: String): T {
return when (T::class) { // Can access T::class!
TextNoteEvent::class -> parseTextNote(json) as T
// ...
}
}
val event = fromJson<TextNoteEvent>(json) // Clean
```
### noinline & crossinline
**noinline**: Prevent specific lambda from being inlined
```kotlin
inline fun foo(
inlined: () -> Unit,
noinline notInlined: () -> Unit // Can be stored, passed around
) {
inlined()
someFunction(notInlined) // Can pass to non-inline function
}
```
**crossinline**: Lambda can't do non-local returns
```kotlin
inline fun foo(crossinline block: () -> Unit) {
launch {
block() // OK: crossinline allows lambda in different context
}
}
```
---
## 6. Value Classes (Opportunity)
**Mental model:** `value class` is a compile-time wrapper with zero runtime overhead. Single property, no boxing.
**Not currently used in Amethyst** - potential optimization.
**Pattern:**
```kotlin
@JvmInline
value class EventId(val hex: String)
@JvmInline
value class PubKey(val hex: String)
// Type safety without runtime cost
fun fetchEvent(eventId: EventId): Event {
// eventId.hex accessed without wrapper object
}
val id = EventId("abc123")
fetchEvent(id) // Type safe
// fetchEvent(PubKey("xyz")) // Compile error!
```
**When to use:**
- Type safety for primitives (IDs, hex strings, timestamps)
- High-frequency allocations (event processing)
- Clear domain types without overhead
**Restrictions:**
- Single property only
- Must be `val`
- Can't have `init` block with logic
- Inline at compile-time, may box in some cases
**Amethyst opportunity:**
```kotlin
// Current (String everywhere, no type safety)
fun fetchEvent(id: String): Event // Could pass wrong string
// With value class
@JvmInline value class EventId(val hex: String)
@JvmInline value class PubKeyHex(val hex: String)
@JvmInline value class Bech32(val encoded: String)
fun fetchEvent(id: EventId): Event // Type safe, zero cost
```
---
## Common Patterns
### Pattern: StateFlow State Management
```kotlin
class MyViewModel {
private val _state = MutableStateFlow(State.Initial)
val state: StateFlow<State> = _state.asStateFlow()
fun loadData() {
viewModelScope.launch {
_state.value = State.Loading
val result = repository.getData()
_state.value = when (result) {
is Success -> State.Success(result.data)
is Error -> State.Error(result.message)
}
}
}
}
sealed class State {
data object Initial : State()
data object Loading : State()
data class Success(val data: List<Item>) : State()
data class Error(val message: String) : State()
}
```
### Pattern: Sealed Result with Generics
```kotlin
sealed interface Result<out T> {
data class Success<T>(val value: T) : Result<T>
data class Error(val exception: Exception) : Result<Nothing>
data object Loading : Result<Nothing>
}
// Use with variance
fun <T> fetchData(): Result<T> = ...
val userResult: Result<User> = fetchData()
val itemResult: Result<List<Item>> = fetchData()
```
### Pattern: Immutable Event Builder
```kotlin
@Immutable
data class Event(
val id: String,
val kind: Int,
val content: String,
val tags: ImmutableList<Tag>
) {
companion object {
fun builder() = EventBuilder()
}
}
class EventBuilder {
private var id: String = ""
private var kind: Int = 1
private var content: String = ""
private val tags = mutableListOf<Tag>()
fun id(value: String) = apply { id = value }
fun kind(value: Int) = apply { kind = value }
fun content(value: String) = apply { content = value }
fun tag(tag: Tag) = apply { tags.add(tag) }
fun build() = Event(id, kind, content, tags.toImmutableList())
}
// Usage
val event = Event.builder()
.id("abc")
.kind(1)
.content("Hello")
.tag(Tag.P("pubkey"))
.build()
```
---
## Delegation Guide
**When to delegate:**
| Topic | Delegate To | This Skill Covers |
|-------|-------------|-------------------|
| Structured concurrency, channels | kotlin-coroutines agent | Flow state patterns only |
| expect/actual, source sets | kotlin-multiplatform skill | Platform-agnostic Kotlin |
| General Compose patterns | compose-expert skill | @Immutable for performance |
| Build configuration | gradle-expert skill | - |
**Ask kotlin-coroutines agent for:**
- Advanced Flow operators (flatMapLatest, combine, zip)
- Channel patterns
- Structured concurrency (supervisorScope, coroutineScope)
- Error handling in coroutines
**This skill teaches:**
- StateFlow/SharedFlow state management
- Sealed hierarchies
- @Immutable for Compose
- DSL builders
- Inline/reified patterns
---
## Anti-Patterns
❌ **Mutable public state:**
```kotlin
val accountState: MutableStateFlow<AccountState> // BAD
```
✅ **Immutable public interface:**
```kotlin
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
```
---
❌ **Sealed class for generic results:**
```kotlin
sealed class Result<T> { // BAD: Can't use variance
data class Success<T>(val value: T) : Result<T>()
}
```
✅ **Sealed interface for generics:**
```kotlin
sealed interface Result<out T> { // GOOD: Covariance
data class Success<T>(val value: T) : Result<T>
}
```
---
❌ **Mutable properties in @Immutable class:**
```kotlin
@Immutable
data class Event(
var content: String // BAD: var breaks immutability
)
```
✅ **All val:**
```kotlin
@Immutable
data class Event(
val content: String
)
```
---
❌ **Passing class explicitly when reified available:**
```kotlin
inline fun <T> parse(json: String, clazz: KClass<T>): T // BAD
```
✅ **Use reified:**
```kotlin
inline fun <reified T> parse(json: String): T // GOOD
```
---
## Quick Reference
### Flow Decision Tree
```
Need to expose state?
YES → StateFlow (always has value, single latest)
NO → Need events? → SharedFlow (optional replay, broadcast)
Need to mutate?
Internal only → MutableStateFlow (private)
Expose publicly → StateFlow via .asStateFlow()
```
### Sealed Decision Tree
```
Need common data in base type?
YES → sealed class
NO → sealed interface
Need generics with variance?
YES → sealed interface
NO → Either works
Need multiple inheritance?
YES → sealed interface
NO → Either works
```
### Inline Decision Tree
```
Passing lambda to function?
Called frequently? → inline (performance)
Need reified? → inline (required)
Need to store/pass lambda? → regular fun (can't inline)
```
---
## Resources
### Official Docs
- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow)
- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html)
- [Inline Functions | Kotlin Docs](https://kotlinlang.org/docs/inline-functions.html)
### Bundled References
- `references/flow-patterns.md` - StateFlow/SharedFlow examples from AccountManager, RelayManager
- `references/sealed-class-catalog.md` - All sealed types in quartz
- `references/dsl-builder-examples.md` - TagArrayBuilder, other DSL patterns
- `references/immutability-patterns.md` - @Immutable usage, data classes, collections
### Codebase Examples
- AccountManager.kt:36-50 - sealed class AccountState, StateFlow pattern
- RelayConnectionManager.kt:44-52 - StateFlow state management
- SignerResult.kt:25-46 - sealed interface with generics
- TextNoteEvent.kt:51-63 - @Immutable event class
- TagArrayBuilder.kt:23-90 - DSL builder pattern, inline function
- OptimizedJsonMapper.kt:48 - inline fun with reified
---
**Version:** 1.0.0
**Last Updated:** 2025-12-30
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
@@ -0,0 +1,602 @@
# DSL Builder Examples
Type-safe fluent APIs and DSL patterns from the codebase.
## Table of Contents
- [TagArrayBuilder Pattern](#tagarraybuilder-pattern)
- [Builder Variations](#builder-variations)
- [DSL Principles](#dsl-principles)
- [Creating Custom DSLs](#creating-custom-dsls)
---
## TagArrayBuilder Pattern
### Core Implementation
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt:23-91`
```kotlin
class TagArrayBuilder<T : IEvent> {
private val tagList = mutableMapOf<String, MutableList<Tag>>()
fun remove(tagName: String): TagArrayBuilder<T> {
tagList.remove(tagName)
return this // Method chaining
}
fun remove(tagName: String, tagValue: String): TagArrayBuilder<T> {
tagList[tagName]?.removeAll { it.valueOrNull() == tagValue }
if (tagList[tagName]?.isEmpty() == true) {
tagList.remove(tagName)
}
return this
}
fun removeIf(
predicate: (Tag, Tag) -> Boolean,
toCompare: Tag
): TagArrayBuilder<T> {
val tagName = toCompare.nameOrNull() ?: return this
tagList[tagName]?.removeAll { predicate(it, toCompare) }
if (tagList[tagName]?.isEmpty() == true) {
tagList.remove(tagName)
}
return this
}
fun add(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList.getOrPut(tag[0], ::mutableListOf).add(tag)
return this
}
fun addFirst(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList.getOrPut(tag[0], ::mutableListOf).add(0, tag)
return this
}
fun addUnique(tag: Array<String>): TagArrayBuilder<T> {
if (tag.isEmpty() || tag[0].isEmpty()) return this
tagList[tag[0]] = mutableListOf(tag) // Replace existing
return this
}
fun addAll(tag: List<Array<String>>): TagArrayBuilder<T> {
tag.forEach(::add)
return this
}
fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray()
fun build() = toTypedArray()
}
// Inline DSL function with lambda receiver
inline fun <T : Event> tagArray(
initializer: TagArrayBuilder<T>.() -> Unit = {}
): TagArray = TagArrayBuilder<T>().apply(initializer).build()
```
### Usage Examples
**Basic usage:**
```kotlin
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", eventId, relay, "reply"))
add(arrayOf("p", pubkey))
add(arrayOf("t", "bitcoin"))
}
```
**Advanced patterns:**
```kotlin
// Remove and add
val tags = tagArray<TextNoteEvent> {
addAll(existingTags)
remove("a") // Remove all address tags
addUnique(arrayOf("client", "Amethyst")) // Replace client tag
}
// Conditional building
val tags = tagArray<TextNoteEvent> {
add(arrayOf("e", rootId, "", "root"))
if (replyToId != null) {
add(arrayOf("e", replyToId, "", "reply"))
}
mentionedPubkeys.forEach { pubkey ->
add(arrayOf("p", pubkey))
}
hashtags.forEach { tag ->
add(arrayOf("t", tag.lowercase()))
}
}
// Custom predicate removal
val tags = tagArray<TextNoteEvent> {
addAll(originalTags)
removeIf(
predicate = { tag, compare -> tag[1] == compare[1] },
toCompare = arrayOf("e", eventIdToRemove)
)
}
```
---
## Builder Variations
### PrivateTagArrayBuilder
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt`
```kotlin
class PrivateTagArrayBuilder {
private val builder = TagArrayBuilder<Event>()
fun add(tag: PrivateTag): PrivateTagArrayBuilder {
builder.add(tag.toArray())
return this
}
fun addAll(tags: List<PrivateTag>): PrivateTagArrayBuilder {
tags.forEach { add(it) }
return this
}
fun build(): Array<Array<String>> = builder.build()
}
// DSL function
inline fun privateTagArray(
initializer: PrivateTagArrayBuilder.() -> Unit
): Array<Array<String>> = PrivateTagArrayBuilder().apply(initializer).build()
```
**Usage:**
```kotlin
val privateTags = privateTagArray {
add(PrivateTag.Event(eventId, marker = "bookmark"))
add(PrivateTag.Profile(pubkey))
addAll(existingPrivateTags)
}
```
### TlvBuilder
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt`
```kotlin
class TlvBuilder {
private val entries = mutableListOf<TlvEntry>()
fun add(type: TlvType, value: ByteArray): TlvBuilder {
entries.add(TlvEntry(type, value))
return this
}
fun addRelay(relay: String): TlvBuilder {
add(TlvType.Relay, relay.encodeToByteArray())
return this
}
fun addAuthor(pubkey: ByteArray): TlvBuilder {
add(TlvType.Author, pubkey)
return this
}
fun addKind(kind: Int): TlvBuilder {
add(TlvType.Kind, kind.toByteArray())
return this
}
fun build(): ByteArray {
return entries.flatMap { it.encode() }.toByteArray()
}
}
fun tlv(init: TlvBuilder.() -> Unit): ByteArray =
TlvBuilder().apply(init).build()
```
**Usage:**
```kotlin
val tlvData = tlv {
addAuthor(pubkeyBytes)
addRelay("wss://relay.damus.io")
addKind(1)
}
```
### MapOfSetBuilder
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt`
```kotlin
class MapOfSetBuilder<K, V> {
private val map = mutableMapOf<K, MutableSet<V>>()
fun add(key: K, value: V): MapOfSetBuilder<K, V> {
map.getOrPut(key) { mutableSetOf() }.add(value)
return this
}
fun addAll(key: K, values: Collection<V>): MapOfSetBuilder<K, V> {
map.getOrPut(key) { mutableSetOf() }.addAll(values)
return this
}
fun remove(key: K, value: V): MapOfSetBuilder<K, V> {
map[key]?.remove(value)
if (map[key]?.isEmpty() == true) {
map.remove(key)
}
return this
}
fun build(): Map<K, Set<V>> = map.mapValues { it.value.toSet() }
}
inline fun <K, V> mapOfSets(
init: MapOfSetBuilder<K, V>.() -> Unit
): Map<K, Set<V>> = MapOfSetBuilder<K, V>().apply(init).build()
```
**Usage:**
```kotlin
val relayMap = mapOfSets<String, EventId> {
add("wss://relay1.com", eventId1)
add("wss://relay1.com", eventId2)
add("wss://relay2.com", eventId3)
}
// Result: {"wss://relay1.com": [eventId1, eventId2], "wss://relay2.com": [eventId3]}
```
---
## DSL Principles
### 1. Lambda with Receiver
**Mental model:** Lambda receiver makes `this` refer to builder instance inside lambda.
```kotlin
// Without receiver
fun buildTags(config: (TagArrayBuilder<Event>) -> Unit) {
val builder = TagArrayBuilder<Event>()
config(builder) // Must pass builder explicitly
builder.build()
}
buildTags { builder ->
builder.add(...) // Verbose
}
// With receiver
inline fun buildTags(config: TagArrayBuilder<Event>.() -> Unit) {
TagArrayBuilder<Event>().apply(config).build()
}
buildTags {
add(...) // Clean - 'this' is builder
}
```
### 2. Method Chaining
**Pattern:** Return `this` from mutator methods.
```kotlin
class Builder {
private var value: String = ""
fun setValue(v: String): Builder {
value = v
return this // Enable chaining
}
fun append(s: String): Builder {
value += s
return this
}
fun build(): String = value
}
// Usage
val result = Builder()
.setValue("Hello")
.append(" ")
.append("World")
.build()
```
### 3. Inline for Performance
**Why inline:**
- Eliminates lambda allocation
- Allows `reified` type parameters
- Better for hot paths (frequently called)
```kotlin
// NOT inline - lambda object created each call
fun <T> myDsl(init: Builder<T>.() -> Unit): Result<T> {
return Builder<T>().apply(init).build()
}
// Inline - lambda code inlined at call site
inline fun <T> myDsl(init: Builder<T>.() -> Unit): Result<T> {
return Builder<T>().apply(init).build()
}
```
### 4. Type Safety
**Use generics for compile-time safety:**
```kotlin
// Type-safe builder
class EventBuilder<T : Event> {
fun addTag(tag: Tag<T>): EventBuilder<T> { // Only accepts tags for this event type
tags.add(tag)
return this
}
}
// Usage
val textNote = EventBuilder<TextNoteEvent>()
.addTag(TextNoteTag.Subject("Hello")) // OK
// .addTag(ChannelTag.Name("test")) // Compile error!
.build()
```
---
## Creating Custom DSLs
### Pattern: Simple Builder DSL
```kotlin
class QueryBuilder {
private val filters = mutableListOf<String>()
private var limit: Int? = null
private var offset: Int? = null
fun filter(field: String, value: String): QueryBuilder {
filters.add("$field:$value")
return this
}
fun limit(n: Int): QueryBuilder {
limit = n
return this
}
fun offset(n: Int): QueryBuilder {
offset = n
return this
}
fun build(): String {
val parts = mutableListOf<String>()
if (filters.isNotEmpty()) {
parts.add(filters.joinToString(" AND "))
}
if (limit != null) {
parts.add("LIMIT $limit")
}
if (offset != null) {
parts.add("OFFSET $offset")
}
return parts.joinToString(" ")
}
}
inline fun query(init: QueryBuilder.() -> Unit): String =
QueryBuilder().apply(init).build()
// Usage
val sql = query {
filter("status", "active")
filter("age", ">18")
limit(10)
offset(20)
}
// Result: "status:active AND age:>18 LIMIT 10 OFFSET 20"
```
### Pattern: Nested Builders
```kotlin
class FilterBuilder {
private val conditions = mutableListOf<String>()
fun equals(field: String, value: String) {
conditions.add("$field = '$value'")
}
fun greaterThan(field: String, value: Int) {
conditions.add("$field > $value")
}
fun build(): String = conditions.joinToString(" AND ")
}
class QueryBuilder {
private var filterClause: String = ""
private var selectClause: String = "*"
fun select(vararg fields: String): QueryBuilder {
selectClause = fields.joinToString(", ")
return this
}
fun where(init: FilterBuilder.() -> Unit): QueryBuilder {
filterClause = FilterBuilder().apply(init).build()
return this
}
fun build(): String {
return "SELECT $selectClause WHERE $filterClause"
}
}
inline fun query(init: QueryBuilder.() -> Unit): String =
QueryBuilder().apply(init).build()
// Usage
val sql = query {
select("id", "name", "age")
where {
equals("status", "active")
greaterThan("age", 18)
}
}
// Result: "SELECT id, name, age WHERE status = 'active' AND age > 18"
```
### Pattern: Type-Safe HTML DSL
```kotlin
abstract class Tag(val name: String) {
private val children = mutableListOf<Tag>()
private val attributes = mutableMapOf<String, String>()
fun <T : Tag> tag(tag: T, init: T.() -> Unit): T {
tag.init()
children.add(tag)
return tag
}
fun attr(name: String, value: String) {
attributes[name] = value
}
fun render(builder: StringBuilder, indent: String) {
builder.append("$indent<$name")
attributes.forEach { (k, v) -> builder.append(" $k=\"$v\"") }
if (children.isEmpty()) {
builder.append("/>\n")
} else {
builder.append(">\n")
children.forEach { it.render(builder, "$indent ") }
builder.append("$indent</$name>\n")
}
}
}
class HTML : Tag("html")
class Head : Tag("head")
class Body : Tag("body")
class Div : Tag("div")
class P : Tag("p")
class A : Tag("a")
fun HTML.head(init: Head.() -> Unit) = tag(Head(), init)
fun HTML.body(init: Body.() -> Unit) = tag(Body(), init)
fun Body.div(init: Div.() -> Unit) = tag(Div(), init)
fun Div.p(init: P.() -> Unit) = tag(P(), init)
fun Div.a(init: A.() -> Unit) = tag(A(), init)
fun html(init: HTML.() -> Unit): HTML = HTML().apply(init)
// Usage
val page = html {
head {
// ...
}
body {
div {
attr("class", "container")
p {
attr("id", "intro")
}
a {
attr("href", "https://example.com")
}
}
}
}
```
---
## Best Practices
### ✅ DO
1. **Return `this` for chaining:**
```kotlin
fun add(item: Item): Builder {
items.add(item)
return this
}
```
2. **Use `inline` for DSL functions:**
```kotlin
inline fun myDsl(init: Builder.() -> Unit) = Builder().apply(init).build()
```
3. **Provide sensible defaults:**
```kotlin
inline fun query(
init: QueryBuilder.() -> Unit = {} // Empty lambda as default
) = QueryBuilder().apply(init).build()
```
4. **Validate in `build()`:**
```kotlin
fun build(): Result {
require(fields.isNotEmpty()) { "Must specify at least one field" }
return Result(fields)
}
```
### ❌ DON'T
1. **Forget to return `this`:**
```kotlin
fun add(item: Item) { // BAD: Can't chain
items.add(item)
}
```
2. **Mutate after build:**
```kotlin
val builder = Builder()
builder.add("foo")
val result = builder.build()
builder.add("bar") // BAD: Confusing state
```
3. **Expose mutable state:**
```kotlin
class Builder {
val items = mutableListOf<Item>() // BAD: Can be mutated externally
}
```
4. **Make DSL functions non-inline unnecessarily:**
```kotlin
fun myDsl(init: Builder.() -> Unit) = ... // BAD: Lambda allocation overhead
```
---
## References
- TagArrayBuilder.kt:23-91
- PrivateTagArrayBuilder.kt
- TlvBuilder.kt
- [Type-Safe Builders | Kotlin Docs](https://kotlinlang.org/docs/type-safe-builders.html)
- [DSLs with Kotlin](https://kt.academy/article/dsl-intro)
@@ -0,0 +1,405 @@
# Flow Patterns in Amethyst
StateFlow and SharedFlow usage patterns from the codebase.
## Table of Contents
- [StateFlow for State Management](#stateflow-for-state-management)
- [Flow Composition](#flow-composition)
- [Common Patterns](#common-patterns)
- [Anti-Patterns](#anti-patterns)
---
## StateFlow for State Management
### AccountManager Pattern
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-115`
```kotlin
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean,
) : AccountState()
}
class AccountManager {
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
fun generateNewAccount(): AccountState.LoggedIn {
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
val state = AccountState.LoggedIn(
signer = signer,
pubKeyHex = keyPair.pubKey.toHexKey(),
npub = keyPair.pubKey.toNpub(),
nsec = keyPair.privKey?.toNsec(),
isReadOnly = false
)
_accountState.value = state // Update state
return state
}
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
// ... validation ...
val state = AccountState.LoggedIn(...)
_accountState.value = state
return Result.success(state)
}
fun logout() {
_accountState.value = AccountState.LoggedOut
}
}
```
**Pattern highlights:**
- Private `MutableStateFlow` for internal mutations
- Public `StateFlow` via `.asStateFlow()` for read-only access
- Sealed class for type-safe state variants
- Initial value required (`AccountState.LoggedOut`)
### RelayConnectionManager Pattern
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt:44-80`
```kotlin
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null,
val messageCount: Int = 0
)
open class RelayConnectionManager(
websocketBuilder: WebsocketBuilder
) : IRelayClientListener {
private val client = NostrClient(websocketBuilder)
// Map of relay URLs to their status
private val _relayStatuses = MutableStateFlow<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
// Delegated StateFlows from client
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
fun addRelay(url: String): NormalizedRelayUrl? {
val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
return normalized
}
fun removeRelay(url: NormalizedRelayUrl) {
_relayStatuses.value = _relayStatuses.value - url // Immutable update (remove from map)
}
private fun updateRelayStatus(
relay: NormalizedRelayUrl,
update: (RelayStatus) -> RelayStatus
) {
_relayStatuses.value = _relayStatuses.value.toMutableMap().apply {
val current = get(relay) ?: RelayStatus(relay, false)
put(relay, update(current))
}
}
// IRelayClientListener implementation
override fun onConnect(relay: NormalizedRelayUrl) {
updateRelayStatus(relay) { it.copy(connected = true, error = null) }
}
override fun onError(relay: NormalizedRelayUrl, error: String) {
updateRelayStatus(relay) { it.copy(connected = false, error = error) }
}
}
```
**Pattern highlights:**
- `Map` as state value for collection tracking
- Immutable map updates (copy with modifications)
- Helper function `updateRelayStatus` for consistent updates
- Delegation pattern (client exposes its own StateFlows)
---
## Flow Composition
### Multiple StateFlows in UI
**Pattern:**
```kotlin
@Composable
fun LoginScreen(accountManager: AccountManager) {
val accountState by accountManager.accountState.collectAsState()
when (accountState) {
is AccountState.LoggedOut -> {
LoginForm(onLogin = { key -> accountManager.loginWithKey(key) })
}
is AccountState.LoggedIn -> {
MainApp(account = accountState as AccountState.LoggedIn)
}
}
}
```
### Observing Multiple Flows
**Pattern:**
```kotlin
@Composable
fun RelayStatusCard(relayManager: RelayConnectionManager) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
Column {
Text("${connectedRelays.size} of ${relayStatuses.size} relays connected")
relayStatuses.forEach { (url, status) ->
RelayRow(
url = url,
connected = status.connected,
error = status.error
)
}
}
}
```
---
## Common Patterns
### Pattern: Immutable State Updates
```kotlin
// Map updates
_relayStatuses.value = _relayStatuses.value + (url to newStatus) // Add
_relayStatuses.value = _relayStatuses.value - url // Remove
_relayStatuses.value = _relayStatuses.value.mapValues { (key, value) ->
if (key == targetUrl) value.copy(connected = true) else value
}
// List updates
_items.value = _items.value + newItem // Append
_items.value = _items.value.filter { it.id != removedId } // Remove
_items.value = _items.value.map { if (it.id == id) it.copy(name = newName) else it } // Update
// Object updates
_user.value = _user.value.copy(name = newName)
```
### Pattern: Conditional State Transitions
```kotlin
fun attemptLogin(credentials: Credentials) {
if (_loginState.value is LoginState.LoggingIn) {
return // Already logging in, ignore
}
_loginState.value = LoginState.LoggingIn
viewModelScope.launch {
try {
val user = repository.login(credentials)
_loginState.value = LoginState.Success(user)
} catch (e: Exception) {
_loginState.value = LoginState.Error(e.message ?: "Login failed")
}
}
}
```
### Pattern: Derived State
```kotlin
class MyViewModel {
private val _items = MutableStateFlow<List<Item>>(emptyList())
val items: StateFlow<List<Item>> = _items.asStateFlow()
// Derived state (computed from items)
val itemCount: StateFlow<Int> = items.map { it.size }
.stateIn(viewModelScope, SharingStarted.Lazily, 0)
val hasItems: StateFlow<Boolean> = items.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
}
// Usage in Compose
@Composable
fun ItemList(viewModel: MyViewModel) {
val itemCount by viewModel.itemCount.collectAsState()
val hasItems by viewModel.hasItems.collectAsState()
if (hasItems) {
Text("$itemCount items")
} else {
Text("No items")
}
}
```
### Pattern: State with Loading/Error
```kotlin
sealed class UiState<out T> {
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
class FeedViewModel {
private val _feedState = MutableStateFlow<UiState<List<Event>>>(UiState.Loading)
val feedState: StateFlow<UiState<List<Event>>> = _feedState.asStateFlow()
fun loadFeed() {
viewModelScope.launch {
_feedState.value = UiState.Loading
try {
val events = repository.getEvents()
_feedState.value = UiState.Success(events)
} catch (e: Exception) {
_feedState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}
// UI
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val state by viewModel.feedState.collectAsState()
when (state) {
is UiState.Loading -> LoadingSpinner()
is UiState.Success -> EventList((state as UiState.Success).data)
is UiState.Error -> ErrorMessage((state as UiState.Error).message)
}
}
```
---
## Anti-Patterns
### ❌ Exposing Mutable State
```kotlin
// BAD: External code can mutate
class BadViewModel {
val state: MutableStateFlow<State> = MutableStateFlow(State.Initial)
}
// Caller can do:
viewModel.state.value = State.Hacked // Bypass internal logic!
```
### ✅ Expose Immutable
```kotlin
// GOOD: Only ViewModel can mutate
class GoodViewModel {
private val _state = MutableStateFlow(State.Initial)
val state: StateFlow<State> = _state.asStateFlow()
fun updateState(newState: State) {
// Controlled mutation with validation
_state.value = newState
}
}
```
---
### ❌ Not Using Immutable Updates
```kotlin
// BAD: Mutating collection doesn't trigger StateFlow update
val list = mutableListOf<Item>()
list.add(newItem)
_items.value = list // Same reference, no update emitted!
```
### ✅ Create New Instance
```kotlin
// GOOD: New list instance
_items.value = _items.value + newItem // New list created, update emitted
```
---
### ❌ StateFlow for Events
```kotlin
// BAD: Events get lost if no collector
class BadViewModel {
val navigationEvent: StateFlow<NavEvent?> = MutableStateFlow(null)
fun navigate(event: NavEvent) {
_navigationEvent.value = event // Lost if UI not observing!
}
}
```
### ✅ SharedFlow for Events
```kotlin
// GOOD: Events queued
class GoodViewModel {
private val _navigationEvent = MutableSharedFlow<NavEvent>(replay = 0)
val navigationEvent: SharedFlow<NavEvent> = _navigationEvent.asSharedFlow()
fun navigate(event: NavEvent) {
viewModelScope.launch {
_navigationEvent.emit(event) // Queued for collector
}
}
}
```
---
### ❌ Blocking Operations in State Update
```kotlin
// BAD: Blocking main thread
fun loadData() {
_state.value = fetchDataFromNetwork() // Blocks!
}
```
### ✅ Async Updates
```kotlin
// GOOD: Use coroutines
fun loadData() {
viewModelScope.launch {
_state.value = UiState.Loading
val data = withContext(Dispatchers.IO) {
fetchDataFromNetwork()
}
_state.value = UiState.Success(data)
}
}
```
---
## References
- AccountManager.kt:36-115
- RelayConnectionManager.kt:44-80
- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow)
- [Hot vs Cold Flows](https://carrion.dev/en/posts/kotlin-flows-hot-cold/)
@@ -0,0 +1,641 @@
# Immutability Patterns
@Immutable annotation, data classes, and immutable collections for Compose performance.
## Table of Contents
- [Why Immutability Matters](#why-immutability-matters)
- [@Immutable Annotation](#immutable-annotation)
- [Data Classes](#data-classes)
- [Immutable Collections](#immutable-collections)
- [Common Patterns](#common-patterns)
- [Performance Impact](#performance-impact)
---
## Why Immutability Matters
### Compose Recomposition
**Mental model:** Compose tracks state changes by comparing references. If an `@Immutable` object reference doesn't change, Compose skips recomposition.
```kotlin
// Without @Immutable - Recomposes on every parent recomposition
data class User(val name: String, val age: Int)
@Composable
fun UserCard(user: User) { // Recomposes unnecessarily
Text(user.name)
}
// With @Immutable - Only recomposes when user reference changes
@Immutable
data class User(val name: String, val age: Int)
@Composable
fun UserCard(user: User) { // Smart recomposition
Text(user.name)
}
```
**Performance difference:**
- Without `@Immutable`: 1000 `UserCard` recompositions per screen update
- With `@Immutable`: 10 `UserCard` recompositions (only changed users)
### Thread Safety
Immutable objects are inherently thread-safe:
```kotlin
@Immutable
data class Event(
val id: String,
val content: String,
val createdAt: Long
)
// Safe to share across coroutines without synchronization
val sharedEvent: Event = fetchEvent()
launch { processEvent(sharedEvent) } // Safe
launch { saveEvent(sharedEvent) } // Safe
```
---
## @Immutable Annotation
### Basic Usage
**Pattern from Amethyst:**
```kotlin
// TextNoteEvent.kt:51-63
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey
) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
// All properties are val (immutable)
// No var properties
// No mutable collections
}
```
**Requirements for @Immutable:**
1. All properties must be `val` (no `var`)
2. All property types must be immutable or primitives
3. No mutable collections (`MutableList`, `MutableMap`)
4. Arrays are allowed (treated as immutable by contract)
5. No public mutable state
### @Immutable vs @Stable
**@Immutable:** Value never changes after construction
```kotlin
@Immutable
data class User(val name: String, val age: Int)
// Once created, user.name and user.age never change
```
**@Stable:** Value can change, but changes are tracked
```kotlin
@Stable
class MutableCounter {
var count by mutableStateOf(0) // Changes tracked by Compose
}
```
**Amethyst uses @Immutable extensively:**
- 173+ event classes annotated with `@Immutable`
- All Nostr events immutable by design
- Critical for feed performance (thousands of events)
---
## Data Classes
### Immutable Data Classes
**Pattern:**
```kotlin
@Immutable
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null,
val messageCount: Int = 0
) {
// Immutable properties only (val)
// Default values allowed
}
```
**Benefits:**
1. **Structural equality:** `equals()` compares values, not references
2. **copy():** Create modified copies without mutation
3. **toString():** Debugging-friendly output
4. **hashCode():** Consistent hashing for collections
5. **componentN():** Destructuring support
### copy() for Updates
**Mental model:** Instead of mutating, create modified copies.
```kotlin
val status = RelayStatus(
url = "wss://relay.damus.io",
connected = false,
error = null
)
// Immutable update
val updatedStatus = status.copy(connected = true)
// Original unchanged
assert(status.connected == false)
assert(updatedStatus.connected == true)
```
**StateFlow pattern:**
```kotlin
private val _relayStatuses = MutableStateFlow<Map<String, RelayStatus>>(emptyMap())
fun updateRelay(url: String, connected: Boolean) {
_relayStatuses.value = _relayStatuses.value.mapValues { (key, status) ->
if (key == url) {
status.copy(connected = connected) // Immutable update
} else {
status
}
}
}
```
### All Properties in Constructor
**Why important for data classes:**
```kotlin
// BAD: Properties outside constructor not included in equals/hashCode
data class User(val name: String) {
var age: Int = 0 // NOT in equals/hashCode/copy!
}
val user1 = User("Alice")
val user2 = User("Alice")
user1.age = 25
user2.age = 30
assert(user1 == user2) // TRUE! age not compared
assert(user1.copy() == user1) // TRUE! age not copied
// GOOD: All properties in constructor
@Immutable
data class User(
val name: String,
val age: Int // Included in equals/hashCode/copy
)
```
---
## Immutable Collections
### kotlinx.collections.immutable
**Installation:**
```kotlin
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
}
```
**Why use:**
- Structural sharing (efficient copies)
- Explicit immutability (compiler enforced)
- Safe for Compose state
### ImmutableList
```kotlin
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
// Create immutable list
val relays: ImmutableList<String> = persistentListOf(
"wss://relay1.com",
"wss://relay2.com"
)
// Add returns NEW list
val updated = relays.add("wss://relay3.com")
assert(relays.size == 2) // Original unchanged
assert(updated.size == 3) // New list has 3 items
// Convert from regular list
val mutableList = mutableListOf("a", "b", "c")
val immutable = mutableList.toImmutableList()
```
### ImmutableMap
```kotlin
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
// Create immutable map
val relayStatuses: ImmutableMap<String, RelayStatus> = persistentMapOf(
"wss://relay1.com" to RelayStatus(...),
"wss://relay2.com" to RelayStatus(...)
)
// Put returns NEW map
val updated = relayStatuses.put("wss://relay3.com", RelayStatus(...))
// Remove returns NEW map
val removed = relayStatuses.remove("wss://relay1.com")
```
### ImmutableSet
```kotlin
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
val connectedRelays: ImmutableSet<String> = persistentSetOf(
"wss://relay1.com",
"wss://relay2.com"
)
val updated = connectedRelays.add("wss://relay3.com")
```
### Structural Sharing
**Mental model:** Immutable collections reuse internal structure for efficiency.
```kotlin
val list1 = persistentListOf(1, 2, 3, 4, 5) // 5 items
val list2 = list1.add(6) // Shares structure with list1
// Internally:
// list1 and list2 share nodes for items 1-5
// list2 has one additional node for item 6
// O(1) time, O(1) space for add operation
```
---
## Common Patterns
### Pattern: Immutable State Updates
```kotlin
@Immutable
data class FeedState(
val events: ImmutableList<Event>,
val loading: Boolean,
val error: String?
)
class FeedViewModel {
private val _state = MutableStateFlow(
FeedState(
events = persistentListOf(),
loading = false,
error = null
)
)
val state: StateFlow<FeedState> = _state.asStateFlow()
fun loadEvents() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
try {
val events = repository.getEvents()
_state.value = _state.value.copy(
events = events.toImmutableList(),
loading = false
)
} catch (e: Exception) {
_state.value = _state.value.copy(
loading = false,
error = e.message
)
}
}
}
fun addEvent(event: Event) {
_state.value = _state.value.copy(
events = _state.value.events.add(event) // Immutable add
)
}
fun removeEvent(eventId: String) {
_state.value = _state.value.copy(
events = _state.value.events.filter { it.id != eventId }.toImmutableList()
)
}
}
```
### Pattern: Deep Immutability
```kotlin
// Nested immutable structures
@Immutable
data class User(
val name: String,
val profile: Profile // Also immutable
)
@Immutable
data class Profile(
val bio: String,
val avatar: String,
val relays: ImmutableList<String> // Immutable collection
)
// Safe deep copy
val user = User(
name = "Alice",
profile = Profile(
bio = "Nostr enthusiast",
avatar = "https://...",
relays = persistentListOf("wss://relay1.com")
)
)
val updatedUser = user.copy(
profile = user.profile.copy(
bio = "Bitcoin & Nostr enthusiast" // Deep update
)
)
```
### Pattern: Collection Builder to Immutable
```kotlin
// Build mutable, convert to immutable
fun processEvents(input: List<Event>): ImmutableList<Event> {
val processed = mutableListOf<Event>()
for (event in input) {
if (event.isValid()) {
processed.add(event.normalize())
}
}
return processed.toImmutableList() // Convert once at end
}
```
### Pattern: Immutable Map Updates
```kotlin
private val _relayStatuses = MutableStateFlow<ImmutableMap<String, RelayStatus>>(
persistentMapOf()
)
fun updateRelay(url: String, connected: Boolean) {
val currentStatuses = _relayStatuses.value
val currentStatus = currentStatuses[url] ?: RelayStatus(url, false)
_relayStatuses.value = currentStatuses.put(
url,
currentStatus.copy(connected = connected)
)
}
fun removeRelay(url: String) {
_relayStatuses.value = _relayStatuses.value.remove(url)
}
```
---
## Performance Impact
### Benchmarks (Approximate)
**Recomposition cost:**
```kotlin
// 1000 items in LazyColumn
// Without @Immutable: ~100ms per frame (skipped frames)
// With @Immutable: ~16ms per frame (smooth 60fps)
@Immutable
data class Item(val id: String, val name: String)
@Composable
fun ItemList(items: ImmutableList<Item>) {
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(item) // Only recomposes when item changes
}
}
}
```
**Structural sharing efficiency:**
```kotlin
val list1 = persistentListOf(1..10000)
val list2 = list1.add(10001) // O(log n) time, shares structure
// Regular list (copy on modification):
val mutableList = (1..10000).toMutableList()
val copy = mutableList.toList() + 10001 // O(n) time, full copy
```
### When to Use Immutable Collections
**Use ImmutableList/Map/Set when:**
- Storing in Compose state (@Immutable class)
- Sharing across coroutines
- Frequent modifications (structural sharing efficient)
- Need compile-time immutability guarantee
**Use Array when:**
- Fixed size, no modifications
- Nostr protocol (tags are `Array<Array<String>>`)
- Performance-critical (array access is fastest)
**Use regular List/Map/Set when:**
- Local scope only
- Build once, read many times
- Converting to immutable at boundary
---
## Anti-Patterns
### ❌ Mutable Properties in @Immutable Class
```kotlin
@Immutable
data class BadEvent(
val id: String,
var content: String // BAD: var breaks immutability
)
```
### ✅ All val Properties
```kotlin
@Immutable
data class GoodEvent(
val id: String,
val content: String
)
```
---
### ❌ Mutable Collections in @Immutable Class
```kotlin
@Immutable
data class BadState(
val items: MutableList<Item> // BAD: Can mutate items
)
// Caller can mutate:
val state = BadState(mutableListOf())
state.items.add(newItem) // Breaks immutability!
```
### ✅ Immutable Collections
```kotlin
@Immutable
data class GoodState(
val items: ImmutableList<Item>
)
// Caller must create new state:
val updated = state.copy(items = state.items.add(newItem))
```
---
### ❌ Direct Mutation
```kotlin
val status = RelayStatus(url, connected = false)
status.connected = true // Compile error (val)
// But could happen with mutable nested objects:
@Immutable
data class Config(
val settings: Settings // If Settings is mutable...
)
class Settings {
var theme: String = "dark" // BAD
}
val config = Config(Settings())
config.settings.theme = "light" // Mutates "immutable" config!
```
### ✅ Deep Immutability
```kotlin
@Immutable
data class Config(
val settings: Settings
)
@Immutable
data class Settings(
val theme: String // val only
)
val config = Config(Settings("dark"))
val updated = config.copy(
settings = config.settings.copy(theme = "light")
)
```
---
### ❌ Exposing Mutable Internal State
```kotlin
@Immutable
class BadViewModel {
private val _items = mutableListOf<Item>()
val items: List<Item> = _items // BAD: Exposes mutable list
fun addItem(item: Item) {
_items.add(item)
}
}
// Caller can cast and mutate:
val vm = BadViewModel()
(vm.items as MutableList).clear() // Breaks encapsulation!
```
### ✅ Convert to Immutable at Boundary
```kotlin
@Immutable
class GoodViewModel {
private val _items = mutableListOf<Item>()
val items: ImmutableList<Item>
get() = _items.toImmutableList() // GOOD: Copy to immutable
fun addItem(item: Item) {
_items.add(item)
}
}
```
---
## Checklist for Immutability
**For @Immutable classes:**
- [ ] All properties are `val`, never `var`
- [ ] No mutable collections (`MutableList`, `MutableMap`, `MutableSet`)
- [ ] Nested objects are also `@Immutable` or primitives
- [ ] No public mutable state
- [ ] Use `copy()` for updates, never mutation
- [ ] Arrays used only when truly immutable by contract
**For StateFlow state:**
- [ ] State class is `@Immutable`
- [ ] Use immutable collections (ImmutableList, ImmutableMap)
- [ ] Create new instances for updates (`copy()`, `.add()`, `.put()`)
- [ ] Never mutate state in-place
**For Compose performance:**
- [ ] All `@Composable` parameters are `@Immutable` or `@Stable`
- [ ] Lists use `ImmutableList` and `key` parameter in `items()`
- [ ] Heavy objects (events, profiles) cached and reused
---
## References
- TextNoteEvent.kt:51-63 - @Immutable event example
- RelayConnectionManager.kt - Immutable map updates
- [Compose Performance | Android Developers](https://developer.android.com/jetpack/compose/performance/stability)
- [kotlinx.collections.immutable | GitHub](https://github.com/Kotlin/kotlinx.collections.immutable)
- [@Stable and @Immutable | Compose Docs](https://developer.android.com/jetpack/compose/performance/stability/fix)
@@ -0,0 +1,482 @@
# Sealed Class Catalog
Comprehensive list of sealed types in AmethystMultiplatform with usage patterns.
## Table of Contents
- [State Management](#state-management)
- [Result Types](#result-types)
- [Tag Variants](#tag-variants)
- [Sealed Class vs Sealed Interface](#sealed-class-vs-sealed-interface)
- [Patterns](#patterns)
---
## State Management
### AccountState (Sealed Class)
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-46`
```kotlin
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean
) : AccountState()
}
```
**Why sealed class:**
- Two distinct states with different data
- `LoggedIn` holds data, `LoggedOut` doesn't
- No need for generics or multiple inheritance
**Usage:**
```kotlin
fun handleAccountState(state: AccountState) {
when (state) {
is AccountState.LoggedOut -> showLogin()
is AccountState.LoggedIn -> {
showFeed(
pubkey = state.pubKeyHex,
canSign = !state.isReadOnly
)
}
} // Exhaustive - compiler enforces
}
```
### VerificationState (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt`
```kotlin
sealed class VerificationState {
data object NotStarted : VerificationState()
data object Started : VerificationState()
data class Failed(val reason: String) : VerificationState()
data object Verified : VerificationState()
}
```
**Pattern:**
- State machine (NotStarted → Started → Failed/Verified)
- Only `Failed` carries data (reason)
- Rest are singletons (`data object`)
---
## Result Types
### SignerResult (Sealed Interface with Generics)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt:25-46`
```kotlin
sealed interface SignerResult<T : IResult> {
sealed interface RequestAddressed<T : IResult> : SignerResult<T> {
class Successful<T : IResult>(val result: T) : RequestAddressed<T>
class Rejected<T : IResult> : RequestAddressed<T>
class TimedOut<T : IResult> : RequestAddressed<T>
class ReceivedButCouldNotPerform<T : IResult>(
val message: String? = null
) : RequestAddressed<T>
class ReceivedButCouldNotParseEventFromResult<T : IResult>(
val eventJson: String
) : RequestAddressed<T>
class ReceivedButCouldNotVerifyResultingEvent<T : IResult>(
val invalidEvent: Event
) : RequestAddressed<T>
}
}
interface IResult
data class SignResult(val event: Event) : IResult
data class EncryptionResult(val ciphertext: String) : IResult
data class DecryptionResult(val plaintext: String) : IResult
```
**Why sealed interface:**
- Generic result type `<T : IResult>`
- Nested sealed hierarchy (RequestAddressed)
- Need covariance for flexible result types
**Usage:**
```kotlin
suspend fun signEvent(event: Event): SignerResult<SignResult> {
return when (val result = remoteSigner.sign(event)) {
is SignerResult.RequestAddressed.Successful -> result
is SignerResult.RequestAddressed.Rejected -> {
logger.warn("Signing rejected")
result
}
is SignerResult.RequestAddressed.TimedOut -> {
logger.error("Signing timed out")
result
}
is SignerResult.RequestAddressed.ReceivedButCouldNotPerform -> {
logger.error("Signer error: ${result.message}")
result
}
}
}
```
### CacheResults (Sealed Class with Generics)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt`
```kotlin
sealed class CacheResults<T> {
data class Found<T>(val value: T) : CacheResults<T>()
class NotFound<T> : CacheResults<T>()
}
```
**Pattern:**
- Simple binary result (found/not found)
- `Found` carries data, `NotFound` doesn't
- Generic for reusability
---
## Tag Variants
### MuteTag (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt`
```kotlin
sealed class MuteTag(
val nameOrNull: String?,
val valueOrNull: String?
) {
class Event(eventId: String) : MuteTag("e", eventId)
class Profile(pubkey: String) : MuteTag("p", pubkey)
class Word(word: String) : MuteTag("word", word)
class Thread(threadId: String) : MuteTag("thread", threadId)
companion object {
fun parse(tag: Array<String>): MuteTag? {
return when (tag.getOrNull(0)) {
"e" -> tag.getOrNull(1)?.let { Event(it) }
"p" -> tag.getOrNull(1)?.let { Profile(it) }
"word" -> tag.getOrNull(1)?.let { Word(it) }
"thread" -> tag.getOrNull(1)?.let { Thread(it) }
else -> null
}
}
}
fun toArray(): Array<String> {
return arrayOf(nameOrNull ?: "", valueOrNull ?: "")
}
}
```
**Pattern:**
- Common base class with shared properties
- Each variant represents different tag type
- Factory method `parse()` for parsing
- `toArray()` for serialization
### BookmarkIdTag (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt`
```kotlin
sealed class BookmarkIdTag {
abstract val id: String
abstract val marker: String?
data class Event(override val id: String, override val marker: String?) : BookmarkIdTag()
data class Profile(override val id: String, override val marker: String?) : BookmarkIdTag()
data class Address(override val id: String, override val marker: String?) : BookmarkIdTag()
companion object {
fun parse(tag: Array<String>): BookmarkIdTag? {
val marker = tag.getOrNull(3)
return when (tag.getOrNull(0)) {
"e" -> tag.getOrNull(1)?.let { Event(it, marker) }
"p" -> tag.getOrNull(1)?.let { Profile(it, marker) }
"a" -> tag.getOrNull(1)?.let { Address(it, marker) }
else -> null
}
}
}
}
```
**Pattern:**
- Abstract properties in sealed class
- Data classes implement abstract properties
- Parse factory returns sealed variant
---
## Exception Hierarchies
### SignerExceptions (Sealed Class)
**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt`
```kotlin
sealed class SignerExceptions(message: String) : Exception(message) {
class UnableToSign(message: String) : SignerExceptions(message)
class UnableToDecrypt(message: String) : SignerExceptions(message)
class UnableToEncrypt(message: String) : SignerExceptions(message)
class UnableToGetPublicKey(message: String) : SignerExceptions(message)
}
```
**Pattern:**
- Sealed exception hierarchy
- Extends `Exception` base class
- Type-safe error handling
**Usage:**
```kotlin
try {
signer.sign(event)
} catch (e: SignerExceptions) {
when (e) {
is SignerExceptions.UnableToSign -> logger.error("Signing failed: ${e.message}")
is SignerExceptions.UnableToDecrypt -> logger.error("Decryption failed: ${e.message}")
is SignerExceptions.UnableToEncrypt -> logger.error("Encryption failed: ${e.message}")
is SignerExceptions.UnableToGetPublicKey -> logger.error("No public key: ${e.message}")
}
}
```
---
## Sealed Class vs Sealed Interface
### When to Use Sealed Class
**Examples from codebase:**
1. **AccountState** - State variants with different data
2. **VerificationState** - State machine
3. **MuteTag** - Tag variants with common base properties
4. **SignerExceptions** - Exception hierarchy
**Characteristics:**
- Need common constructor parameters
- Single inheritance only
- State variants
- Exception hierarchies
### When to Use Sealed Interface
**Examples from codebase:**
1. **SignerResult<T>** - Generic result types needing variance
2. **RelayUrlNormalizer.Result** - Binary result with no shared state
**Characteristics:**
- Need generics with variance (`out`, `in`)
- No common state needed
- Multiple inheritance possible
- Contract/capability representation
---
## Patterns
### Pattern: State Machine
```kotlin
sealed class ConnectionState {
data object Disconnected : ConnectionState()
data object Connecting : ConnectionState()
data class Connected(val relay: String) : ConnectionState()
data class Failed(val error: String) : ConnectionState()
}
// Allowed transitions
fun transition(from: ConnectionState, event: Event): ConnectionState {
return when (from) {
is ConnectionState.Disconnected -> {
when (event) {
is Event.Connect -> ConnectionState.Connecting
else -> from
}
}
is ConnectionState.Connecting -> {
when (event) {
is Event.Success -> ConnectionState.Connected(event.relay)
is Event.Error -> ConnectionState.Failed(event.message)
is Event.Cancel -> ConnectionState.Disconnected
else -> from
}
}
is ConnectionState.Connected -> {
when (event) {
is Event.Disconnect -> ConnectionState.Disconnected
is Event.Error -> ConnectionState.Failed(event.message)
else -> from
}
}
is ConnectionState.Failed -> {
when (event) {
is Event.Retry -> ConnectionState.Connecting
is Event.Cancel -> ConnectionState.Disconnected
else -> from
}
}
}
}
```
### Pattern: Result Type
```kotlin
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Exception) : Result<Nothing>
data object Loading : Result<Nothing>
}
// Extension functions
fun <T> Result<T>.getOrNull(): T? = when (this) {
is Result.Success -> data
else -> null
}
fun <T> Result<T>.getOrThrow(): T = when (this) {
is Result.Success -> data
is Result.Error -> throw exception
is Result.Loading -> error("Still loading")
}
fun <T, R> Result<T>.map(transform: (T) -> R): Result<R> = when (this) {
is Result.Success -> Result.Success(transform(data))
is Result.Error -> this
is Result.Loading -> Result.Loading
}
```
### Pattern: Tagged Union (Discriminated Union)
```kotlin
sealed class Command {
data class SendEvent(val event: Event) : Command()
data class Subscribe(val filters: List<Filter>) : Command()
data class Unsubscribe(val subId: String) : Command()
data object Close : Command()
fun toJson(): String = when (this) {
is SendEvent -> """["EVENT",${event.toJson()}]"""
is Subscribe -> """["REQ","sub",${filters.joinToString { it.toJson() }}]"""
is Unsubscribe -> """["CLOSE","$subId"]"""
is Close -> """["CLOSE"]"""
}
}
```
### Pattern: Nested Sealed Hierarchies
```kotlin
sealed interface UiState {
sealed interface Loading : UiState {
data object Initial : Loading
data class Refreshing(val currentData: List<Item>) : Loading
}
sealed interface Content : UiState {
data class Success(val data: List<Item>) : Content
data object Empty : Content
}
sealed interface Error : UiState {
data class Network(val message: String) : Error
data class Server(val code: Int, val message: String) : Error
}
}
// Usage
fun renderUi(state: UiState) {
when (state) {
is UiState.Loading.Initial -> showFullScreenLoader()
is UiState.Loading.Refreshing -> showRefreshIndicator(state.currentData)
is UiState.Content.Success -> showList(state.data)
is UiState.Content.Empty -> showEmptyState()
is UiState.Error.Network -> showNetworkError(state.message)
is UiState.Error.Server -> showServerError(state.code, state.message)
}
}
```
---
## All Sealed Types in Quartz
**Complete list of sealed types found in codebase:**
### Commons
- AccountState (class)
### Quartz
- BaseZapSplitSetup (class)
- MuteTag (class)
- BookmarkIdTag (class)
- SignerResult (interface)
- VerificationState (class)
- CacheResults (class)
- SignerExceptions (class)
- RelayUrlNormalizer.Result (interface)
**Total:** 8 sealed types (7 classes, 1 interface)
---
## Decision Tree
```
Need to represent variants of a concept?
YES → Use sealed type
NO → Regular class/interface
Variants have different data?
YES → sealed class or sealed interface
NO → enum (if simple constants)
Need generics with variance (out/in)?
YES → sealed interface
NO → sealed class (simpler)
Need common constructor/properties?
YES → sealed class
NO → sealed interface
Need multiple inheritance?
YES → sealed interface
NO → Either works
Representing state machine?
→ sealed class (state transitions)
Representing result/error types?
→ sealed interface (if generic, else class)
Representing tag/command variants?
→ sealed class (common structure)
```
---
## References
- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html)
- [Effective Kotlin: Sealed Classes](https://kt.academy/article/ek-sealed-classes)
- [Complete Guide: Sealed Classes & Interfaces 2025](https://proandroiddev.com/complete-technical-guide-sealed-classes-sealed-interfaces-enums-in-kotlin-28ffc39116df)
@@ -0,0 +1,400 @@
---
name: kotlin-multiplatform
description: |
Platform abstraction decision-making for Amethyst KMP project. Guides when to abstract vs keep platform-specific,
source set placement (commonMain, jvmAndroid, platform-specific), expect/actual patterns. Covers primary targets
(Android, JVM/Desktop, iOS) with web/wasm future considerations. Integrates with gradle-expert for dependency issues.
Triggers on: abstraction decisions ("should I share this?"), source set placement questions, expect/actual creation,
build.gradle.kts work, incorrect placement detection, KMP dependency suggestions.
---
# Kotlin Multiplatform: Platform Abstraction Decisions
Expert guidance for KMP architecture in Amethyst - deciding what to share vs keep platform-specific.
## When to Use This Skill
Making platform abstraction decisions:
- "Should I create expect/actual or keep Android-only?"
- "Can I share this ViewModel logic?"
- "Where does this crypto/JSON/network implementation belong?"
- "This uses Android Context - can it be abstracted?"
- "Is this code in the wrong module?"
- Preparing for iOS/web/wasm targets
- Detecting incorrect placements
## Abstraction Decision Tree
**Central question:** "Should this code be reused across platforms?"
Follow this decision path (< 1 minute):
```
Q: Is it used by 2+ platforms?
├─ NO → Keep platform-specific
│ Example: Android-only permission handling
└─ YES → Continue ↓
Q: Is it pure Kotlin (no platform APIs)?
├─ YES → commonMain
│ Example: Nostr event parsing, business rules
└─ NO → Continue ↓
Q: Does it vary by platform or by JVM vs non-JVM?
├─ By platform (Android ≠ iOS ≠ Desktop)
│ → expect/actual
│ Example: Secp256k1Instance (uses different security APIs)
├─ By JVM (Android = Desktop ≠ iOS/web)
│ → jvmAndroid
│ Example: Jackson JSON parsing (JVM library)
└─ Complex/UI-related
→ Keep platform-specific
Example: Navigation (Activity vs Window too different)
Final check:
Q: Maintenance cost of abstraction < duplication cost?
├─ YES → Proceed with abstraction
└─ NO → Duplicate (simpler)
```
### Real Examples from Codebase
**Crypto → expect/actual:**
```kotlin
// commonMain - expect declaration
expect object Secp256k1Instance {
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
}
// androidMain - uses Android Keystore
// jvmMain - uses Desktop JVM crypto
// iosMain - uses iOS Security framework
```
**Why:** Each platform has different security APIs.
**JSON parsing → jvmAndroid:**
```kotlin
// quartz/build.gradle.kts
val jvmAndroid = create("jvmAndroid") {
api(libs.jackson.module.kotlin)
}
```
**Why:** Jackson is JVM-only, works on Android + Desktop, not iOS/web.
**Navigation → platform-specific:**
- Android: `MainActivity` (Activity + Compose Navigation)
- Desktop: `Window` + sidebar + MenuBar
**Why:** UI paradigms fundamentally different.
## Mental Model: Source Sets as Dependency Graph
Think of source sets as a dependency graph, not folders.
```
┌─────────────────────────────────────────────┐
│ commonMain = Contract (pure Kotlin) │
│ - Business logic, protocol, data models │
│ - No platform APIs │
└────────────┬────────────────────────────────┘
├──────────────────────┬────────────────────
│ │
▼ ▼
┌───────────────────┐ ┌──────────────────┐
│ jvmAndroid │ │ iosMain │
│ JVM libs shared │ │ iOS common │
│ - Jackson │ │ │
│ - OkHttp │ └────┬─────────────┘
└───┬───────────┬───┘ │
│ │ ├─→ iosX64Main
▼ ▼ ├─→ iosArm64Main
┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main
│android │ │jvmMain │
│Main │ │(Desktop) │
└─────────┘ └──────────┘
Future: jsMain, wasmMain
```
**Key insight:** jvmAndroid is NOT a platform - it's a shared JVM layer.
## The jvmAndroid Pattern
**Unique to Amethyst.** Shares JVM libraries between Android + Desktop.
### When to Use jvmAndroid
Use jvmAndroid when:
- ✅ JVM-specific libraries (Jackson, OkHttp, url-detector)
- ✅ Android implementation = Desktop implementation (same JVM)
- ✅ Library doesn't work on iOS/web
Do NOT use jvmAndroid for:
- ❌ Pure Kotlin code (use commonMain)
- ❌ Platform-specific APIs (use androidMain/jvmMain)
- ❌ Code that should work on all platforms
### Example from quartz/build.gradle.kts
```kotlin
// Must be defined BEFORE androidMain and jvmMain
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
api(libs.jackson.module.kotlin) // JSON parsing - JVM only
api(libs.url.detector) // URL extraction - JVM only
implementation(libs.okhttp) // HTTP client - JVM only
}
}
// Both depend on jvmAndroid
jvmMain { dependsOn(jvmAndroid) }
androidMain { dependsOn(jvmAndroid) }
```
**Why Jackson in jvmAndroid, not commonMain?**
- Jackson is JVM-specific library
- Works on Android (runs on JVM)
- Works on Desktop (runs on JVM)
- Does NOT work on iOS (not JVM) or web (not JVM)
**Web/wasm consideration:** For future web support, consider migrating from Jackson → kotlinx.serialization (see Target-Specific Guidance).
## What to Abstract vs Keep Platform-Specific
Quick decision guidelines based on codebase patterns:
### Always Abstract
- **Crypto** (Secp256k1, encryption, signing)
- **Core protocol logic** (Nostr events, NIPs)
- **Why:** Needed everywhere, platform security APIs vary
### Often Abstract
- **I/O operations** (file reading, caching)
- **Logging** (platform logging systems differ)
- **Serialization** (if using kotlinx.serialization)
- **Why:** Commonly reused, platform implementations available
### Sometimes Abstract
- **Business logic:** YES - state machines, data processing
- **UI state:** NO - ViewModels platform-specific
- **Why:** Separate concerns, business logic reusable
### Rarely Abstract
- **UI components** (composables with platform dependencies)
- **Why:** Platform paradigms differ (bottom nav vs sidebar)
### Never Abstract
- **Navigation** (Activity vs Window fundamentally different)
- **Permissions** (Android vs iOS APIs incompatible)
- **Platform UX patterns**
- **Why:** Too platform-specific, abstraction creates leaky APIs
### Evidence from shared-ui-analysis.md
| Component | Shared? | Rationale |
|-----------|---------|-----------|
| PubKeyFormatter, ZapFormatter | ✅ YES | Pure Kotlin, no platform APIs |
| TimeAgoFormatter | ⚠️ ABSTRACTED | Needs StringProvider for localized strings |
| Navigation (INav) | ❌ NO | Activity vs Window too different |
| AccountViewModel | ⚠️ PARTIAL | Business logic → IAccountState (shared), UI state → platform ViewModels |
| Image loading (Coil) | ⚠️ ABSTRACTED | Coil 3.x supports KMP, needs expect/actual wrapper |
## expect/actual Mechanics
**When to use:** Code needed by 2+ platforms, varies by platform.
### Pattern Categories from Codebase
**Objects (singletons):**
```kotlin
// 24 expect declarations found, common pattern:
expect object Secp256k1Instance { ... }
expect object Log { ... }
expect object LibSodiumInstance { ... }
```
**Classes (instantiable):**
```kotlin
expect class AESCBC { ... }
expect class DigestInstance { ... }
```
**Functions (utilities):**
```kotlin
expect fun platform(): String
expect fun currentTimeSeconds(): Long
```
**See** [references/expect-actual-catalog.md](references/expect-actual-catalog.md) for complete catalog with rationale.
## Target-Specific Guidance
### Android, JVM (Desktop), iOS - Current Primary Targets
**Status:** Mature patterns, stable APIs
**Android (androidMain):**
- Uses Android framework (Activity, Context, etc.)
- secp256k1-kmp-jni-android for crypto
- AndroidX libraries
**Desktop JVM (jvmMain):**
- Uses Compose Desktop (Window, MenuBar, etc.)
- secp256k1-kmp-jni-jvm for crypto
- Pure JVM libraries
**iOS (iosMain):**
- Active development, framework configured
- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main
- Platform APIs via platform.posix, Security framework
### Web, wasm - Future Targets
**Status:** Not yet implemented, consider for future-proofing
**Constraints to know:**
- ❌ No platform.posix (file I/O different)
- ❌ No JVM libraries (Jackson, OkHttp won't work)
- ❌ Different async model (JS event loop vs threads)
**Future-proofing tips:**
1. Prefer pure Kotlin in commonMain
2. Use kotlinx.* libraries:
- kotlinx.serialization instead of Jackson
- ktor instead of OkHttp (ktor supports web)
- kotlinx.datetime instead of custom date handling
3. Avoid platform.posix for file operations
4. Test abstractions work without JVM assumptions
**Example migration path:**
```kotlin
// Current: jvmAndroid (JVM-only)
api(libs.jackson.module.kotlin)
// Future: commonMain (all platforms)
api(libs.kotlinx.serialization.json)
```
## Integration: When to Invoke Other Skills
### Invoke gradle-expert
Trigger gradle-expert skill when encountering:
- Dependency conflicts (e.g., secp256k1-android vs secp256k1-jvm version mismatch)
- Build errors related to source sets
- Version catalog issues (libs.versions.toml)
- "Duplicate class" errors
- Performance/build time issues
**Example trigger:**
```
Error: Duplicate class found: fr.acinq.secp256k1.Secp256k1
```
→ Invoke gradle-expert for dependency conflict resolution.
### Flags to Raise
**Platform code in commonMain:**
```kotlin
// ❌ INCORRECT - Android API in commonMain
expect fun getContext(): Context // Context is Android-only!
```
→ Flag: "Android API in commonMain won't compile on other platforms"
**Duplicated business logic:**
```kotlin
// ❌ INCORRECT - Same logic in both
// androidMain/.../CryptoUtils.kt
fun validateSignature(...) { ... }
// jvmMain/.../CryptoUtils.kt
fun validateSignature(...) { ... } // Duplicated!
```
→ Flag: "Business logic duplicated, should be in commonMain or expect/actual"
**Reinventing wheel - suggest KMP alternatives:**
- Custom date/time → kotlinx.datetime
- OkHttp → ktor (supports web)
- Jackson → kotlinx.serialization
- Custom UUID → kotlinx.uuid (when stable)
## Common Pitfalls
### 1. Over-Abstraction
**Problem:** Creating expect/actual for UI components
```kotlin
// ❌ BAD
expect fun NavigationComponent(...)
```
**Why:** Navigation paradigms too different (Activity vs Window)
**Fix:** Keep platform-specific, accept duplication
### 2. Under-Sharing
**Problem:** Duplicating business logic across platforms
```kotlin
// ❌ BAD - duplicated in androidMain and jvmMain
fun parseNostrEvent(json: String): Event { ... }
```
**Why:** Bug fixes need to be applied twice, tests duplicated
**Fix:** Move to commonMain (pure Kotlin) or create expect/actual
### 3. Leaky Abstractions
**Problem:** Platform code in commonMain
```kotlin
// commonMain - ❌ BAD
import android.content.Context // Won't compile on iOS!
```
**Fix:** Use expect/actual or dependency injection
### 4. Premature Abstraction
**Problem:** Creating expect/actual before second platform needs it
```kotlin
// ❌ BAD - only used on Android currently
expect fun showNotification(...)
```
**Why:** Wrong abstraction boundaries, wasted effort
**Fix:** Wait until iOS actually needs it, then abstract
### 5. Wrong Source Set
**Problem:** JVM libraries in commonMain
```kotlin
// commonMain - ❌ BAD
import com.fasterxml.jackson.databind.ObjectMapper
```
**Why:** Jackson won't compile on iOS/web
**Fix:** Move to jvmAndroid or migrate to kotlinx.serialization
## Quick Reference
| Code Type | Recommended Location | Reason |
|-----------|---------------------|--------|
| Pure Kotlin business logic | commonMain | Works everywhere |
| Nostr protocol, NIPs | commonMain | Core logic, no platform APIs |
| JVM libs (Jackson, OkHttp) | jvmAndroid | Android + Desktop only |
| Crypto (varies by platform) | expect in commonMain, actual in platforms | Different security APIs per platform |
| I/O, logging | expect in commonMain, actual in platforms | Platform implementations differ |
| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns |
| State (UI) | Platform ViewModels | Platform-specific lifecycle |
| UI formatters (pure) | commons/commonMain | Reusable, no dependencies |
| UI components (complex) | Platform-specific | Paradigms differ |
| Navigation | Platform-specific only | Activity vs Window too different |
| Permissions | Platform-specific only | APIs incompatible |
| Platform UX (menus, etc.) | Platform-specific only | Native feel required |
## See Also
- [references/abstraction-examples.md](references/abstraction-examples.md) - Good/bad abstraction examples with rationale
- [references/source-set-hierarchy.md](references/source-set-hierarchy.md) - Visual hierarchy with Amethyst examples
- [references/expect-actual-catalog.md](references/expect-actual-catalog.md) - All 24 expect/actual pairs with "why abstracted"
- [references/target-compatibility.md](references/target-compatibility.md) - Platform constraints and future-proofing
## Scripts
- `scripts/validate-kmp-structure.sh` - Detect incorrect placements, validate source sets
- `scripts/suggest-kmp-dependency.sh` - Suggest KMP library alternatives (ktor, kotlinx.serialization, etc.)
@@ -0,0 +1,311 @@
# Abstraction Examples from Amethyst Codebase
Real examples of abstraction decisions with rationale.
## Good Abstractions (Why They Work)
### 1. Secp256k1Instance - Crypto Signing
**Location:** expect in commonMain, actual in androidMain/jvmMain/iosMain
**Code:**
```kotlin
// quartz/src/commonMain/.../Secp256k1Instance.kt
expect object Secp256k1Instance {
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
fun verifySchnorr(signature: ByteArray, hash: ByteArray, pubKey: ByteArray): Boolean
}
```
**Why abstracted:**
- Used by all platforms (Android, Desktop, iOS)
- Security APIs fundamentally different:
- Android: secp256k1-kmp-jni-android (Android Keystore integration)
- Desktop: secp256k1-kmp-jni-jvm (pure JVM crypto)
- iOS: Native Security framework
- Core protocol requirement (Nostr signatures)
**Decision rationale:** Always abstract crypto - varies by platform security APIs, critical for all platforms.
---
### 2. Log - Platform Logging
**Location:** expect object in commonMain
**Code:**
```kotlin
// quartz/src/commonMain/.../Log.kt
expect object Log {
fun d(tag: String, message: String)
fun w(tag: String, message: String, throwable: Throwable?)
fun e(tag: String, message: String, throwable: Throwable?)
}
```
**Why abstracted:**
- Used throughout quartz module (protocol library)
- Logging systems differ:
- Android: android.util.Log
- Desktop: println or logging framework
- iOS: NSLog or OSLog
- Simple interface, easy to implement
**Decision rationale:** Often abstract logging - platform systems differ, widely used, simple interface.
---
### 3. Platform Utils - Time & Platform Name
**Location:** expect functions in commonMain
**Code:**
```kotlin
// quartz/src/commonMain/.../Platform.kt
expect fun platform(): String
expect fun currentTimeSeconds(): Long
```
**Why abstracted:**
- Used by Nostr event creation (timestamps)
- Platform name for debugging
- Simple utilities, clear platform boundary
**Decision rationale:** Platform utilities are good abstraction candidates - simple, useful everywhere.
---
### 4. Jackson JSON (jvmAndroid Pattern)
**Location:** jvmAndroid source set
**Code:**
```kotlin
// quartz/build.gradle.kts
val jvmAndroid = create("jvmAndroid") {
api(libs.jackson.module.kotlin) // JVM-only library
}
```
**Why jvmAndroid (not commonMain):**
- Jackson is JVM-specific library
- Works on Android (JVM) + Desktop (JVM)
- Does NOT work on iOS (not JVM) or web (not JVM)
- Performance-critical JSON parsing
**Decision rationale:** Use jvmAndroid for JVM libraries shared between Android and Desktop.
**Future consideration:** For web support, migrate to kotlinx.serialization (works on all platforms).
---
## Bad/Over-Abstractions (Why They Failed)
### 1. Navigation Abstraction (Avoided)
**What COULD have been done:**
```kotlin
// ❌ Over-abstraction - DON'T DO THIS
expect interface Navigator {
fun navigate(route: String)
fun popBackStack()
}
```
**Why NOT abstracted:**
- Navigation paradigms fundamentally different:
- Android: Activity + Compose Navigation + back stack
- Desktop: Window + screen state + no back stack concept
- Complex APIs don't map well
- Creates leaky abstraction
**Actual approach:** Keep platform-specific
- Android: `INav` interface + Compose Navigation
- Desktop: Simple screen enum + state
**Decision rationale:** Never abstract navigation - platforms too different, abstraction would be leaky.
---
### 2. String Resources (Abstraction Planned)
**Current state:** Platform-specific (over-duplication)
**Problem:**
```kotlin
// Android uses R.string.*
Text(stringResource(R.string.post_not_found))
// Desktop uses hardcoded strings
Text("Post not found")
```
**Why NOT yet abstracted:** Waiting for second platform to fully implement UI, then will create StringProvider interface.
**Planned abstraction:**
```kotlin
// commonMain
interface StringProvider {
fun get(key: String): String
}
// androidMain
class AndroidStringProvider(context: Context): StringProvider { ... }
// jvmMain
class DesktopStringProvider: StringProvider { ... }
```
**Lesson:** Don't abstract prematurely - wait until second platform needs it, then create proper abstraction.
---
## Platform-Specific Code (Why NOT Abstracted)
### 1. MainActivity (Android Activity)
**Location:** amethyst/src/main/.../MainActivity.kt
**Code:**
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
setContent {
AmethystTheme {
AccountScreen(accountStateViewModel)
}
}
}
}
```
**Why platform-specific:**
- AppCompatActivity is Android framework
- Activity lifecycle unique to Android
- enableEdgeToEdge() is Android-specific API
- No equivalent on Desktop (uses Window)
**Decision rationale:** Android Activity is platform-specific by nature.
---
### 2. Desktop Window & MenuBar
**Location:** desktopApp/src/jvmMain/.../Main.kt
**Code:**
```kotlin
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Amethyst"
) {
MenuBar {
Menu("File") {
Item("New Note", onClick = { ... }, shortcut = KeyShortcut(Key.N, ctrl = true))
Item("Quit", onClick = ::exitApplication)
}
}
NavigationRail { ... } // Sidebar navigation
}
}
```
**Why platform-specific:**
- Window, MenuBar, NavigationRail are Compose Desktop APIs
- Keyboard shortcuts (Ctrl+N) are desktop paradigm
- Sidebar navigation vs Android bottom nav
- No equivalent on Android
**Decision rationale:** Desktop UX patterns are platform-specific by nature.
---
### 3. AccountViewModel (Android ViewModel)
**Location:** amethyst/.../AccountStateViewModel.kt
**Partially abstracted:**
- Business logic → IAccountState interface (can be shared)
- UI state + lifecycle → AndroidX ViewModel (Android-only)
**Why not fully abstracted:**
- AndroidX ViewModel lifecycle tied to Android
- Desktop doesn't need ViewModel (simpler state management)
- SavedStateHandle is Android-specific
**Decision rationale:** Extract business logic to interface, keep UI state platform-specific.
---
## Migration Examples (Android → Shared)
### Example 1: PubKeyFormatter (Pure Kotlin)
**Before:**
```kotlin
// amethyst/ui/note/PubKeyFormatter.kt
fun String.toDisplayHexKey(): String {
return "${take(8)}:${takeLast(8)}"
}
```
**After:**
```kotlin
// commons/commonMain/formatters/PubKeyFormatter.kt
fun String.toDisplayHexKey(): String {
return "${take(8)}:${takeLast(8)}"
}
// Both apps use it
import com.vitorpamplona.amethyst.commons.formatters.toDisplayHexKey
```
**Why successful:**
- Pure Kotlin, no platform dependencies
- Widely reused
- Simple utility function
---
### Example 2: TimeAgoFormatter (Requires Abstraction)
**Problem:**
```kotlin
// Uses Android R.string.*
fun timeAgo(timestamp: Long): String {
return context.getString(R.string.x_minutes_ago, minutes)
}
```
**Solution:** Abstract string resources
```kotlin
// commonMain
fun timeAgo(timestamp: Long, stringProvider: StringProvider): String {
return stringProvider.get("x_minutes_ago", minutes)
}
// androidMain
stringProvider = AndroidStringProvider(context)
// jvmMain
stringProvider = DesktopStringProvider()
```
**Why successful:** Clear platform boundary (string resources), useful on both platforms.
---
## Decision Pattern Summary
| Pattern | Abstract? | Why |
|---------|-----------|-----|
| Pure Kotlin utilities | ✅ YES | No platform dependency, easy |
| Crypto APIs | ✅ YES (expect/actual) | Platform security APIs differ |
| JVM libraries | ⚠️ jvmAndroid | Works on Android+Desktop only |
| UI components (simple) | ✅ YES | Composables work cross-platform |
| UI components (complex) | ❌ NO | Platform dependencies |
| Navigation | ❌ NO | Paradigms too different |
| ViewModels | ⚠️ PARTIAL | Business logic yes, UI state no |
| String resources | ⚠️ PLANNED | Needs abstraction layer |
@@ -0,0 +1,163 @@
# Complete expect/actual Catalog
All 24 expect declarations in Amethyst quartz module with rationale.
| # | Name | Type | Purpose | Why Abstracted | Files |
|---|------|------|---------|----------------|-------|
| 1 | AESCBC | class | AES CBC encryption | Platform crypto APIs differ | quartz/.../ciphers/AESCBC.kt |
| 2 | AESGCM | class | AES GCM encryption | Platform crypto APIs differ | quartz/.../ciphers/AESGCM.kt |
| 3 | DigestInstance | class | Hash digests (SHA256) | Platform implementations | quartz/.../diggest/DigestInstance.kt |
| 4 | MacInstance | class | MAC (HMAC) operations | Platform crypto APIs | quartz/.../mac/MacInstance.kt |
| 5 | Sha256 | object | SHA256 hashing | Platform-specific optimizations | quartz/.../sha256/Sha256.kt |
| 6 | LargeCache | object | Large object caching | Platform storage APIs differ | quartz/.../cache/LargeCache.kt |
| 7 | UriParser | object | URI parsing | Platform URL APIs differ | quartz/.../UriParser.kt |
| 8 | UrlEncoder | object | URL encoding | Platform encoding differs | quartz/.../UrlEncoder.kt |
| 9 | Urls | object | URL utilities | Platform URL handling | quartz/.../Urls.kt |
| 10 | Platform | functions | platform(), currentTimeSeconds() | Platform name & time APIs | quartz/.../Platform.kt |
| 11 | Rfc3986 | object | RFC 3986 URL normalization | Used in jvmAndroid | quartz/.../Rfc3986.kt |
| 12 | Secp256k1Instance | object | Bitcoin crypto (secp256k1) | Different libs per platform | quartz/.../Secp256k1Instance.kt |
| 13 | SecureRandom | object | Cryptographically secure random | Platform random APIs differ | quartz/.../SecureRandom.kt |
| 14 | StringExt | functions | String utilities | Platform string handling | quartz/.../StringExt.kt |
| 15 | UnicodeNormalizer | object | Unicode normalization | Platform text APIs | quartz/.../UnicodeNormalizer.kt |
| 16 | GZip | object | GZip compression | Platform compression APIs | quartz/.../GZip.kt |
| 17 | LibSodiumInstance | object | NaCl/libsodium (NIP-44 encryption) | Different libs per platform | quartz/.../LibSodiumInstance.kt |
| 18 | Log | object | Logging | Platform logging systems | quartz/.../Log.kt |
| 19 | BigDecimal | class | Arbitrary precision decimal | Not in Kotlin common stdlib | quartz/.../BigDecimal.kt |
| 20 | BitSet | class | Bit set data structure | Not in Kotlin common stdlib | quartz/.../BitSet.kt |
| 21 | ServerInfoParser | object | Server info parsing (NIP-96) | Platform JSON parsing | quartz/.../nip96.../ServerInfoParser.kt |
| 22 | EventHasherSerializer | object | Event hashing | Platform-specific optimizations | quartz/.../nip01Core.../EventHasherSerializer.kt |
| 23 | OptimizedJsonMapper | object | JSON mapping | Platform JSON libraries | quartz/.../nip01Core.../OptimizedJsonMapper.kt |
| 24 | Address | data class | Address data structure | Platform-specific string handling | quartz/.../nip01Core.../Address.kt |
## Pattern Analysis
### Objects (Singletons) - 19 total
Most common pattern for platform-specific singletons:
- Crypto: Secp256k1Instance, LibSodiumInstance, Sha256
- I/O: UriParser, UrlEncoder, GZip
- Utils: Log, Platform, SecureRandom
### Classes (Instantiable) - 4 total
For objects that need to maintain state:
- AESCBC, AESGCM (cipher state)
- DigestInstance, MacInstance (hash/MAC state)
- BigDecimal, BitSet (data structures)
### Functions - 2 total
Simple utilities:
- platform(), currentTimeSeconds()
## Why Abstracted Categories
### Crypto (8 items)
**Always abstract:** Security APIs fundamentally different across platforms
- Android: Android Keystore, secp256k1-android
- Desktop: JVM crypto, secp256k1-jvm
- iOS: Security framework, native crypto
### I/O & Platform Utils (7 items)
**Often abstract:** File systems, URLs, compression differ
- Platform storage APIs
- URL handling varies
- Compression libraries differ
### Data Structures (2 items)
**Abstract when missing:** Not available in Kotlin common stdlib
- BigDecimal, BitSet not in common
### JSON/Parsing (3 items)
**Platform-specific optimization:** Uses platform JSON libraries
- Android/Desktop: Jackson (via jvmAndroid)
- iOS: Native parsers
### Logging (1 item)
**Always abstract:** Platform logging systems differ
- Android: android.util.Log
- Desktop: println or logging framework
- iOS: NSLog or OSLog
## Actual Implementation Examples
### Simple Object Pattern
```kotlin
// commonMain
expect object Log {
fun d(tag: String, message: String)
}
// androidMain
actual object Log {
actual fun d(tag: String, message: String) {
android.util.Log.d(tag, message)
}
}
// jvmMain
actual object Log {
actual fun d(tag: String, message: String) {
println("[$tag] $message")
}
}
```
### Complex Object with Dependencies
```kotlin
// commonMain
expect object Secp256k1Instance {
fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray
}
// androidMain - uses JNI bindings
actual object Secp256k1Instance {
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null)
}
}
// jvmMain - different JNI library
actual object Secp256k1Instance {
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null)
}
}
// iosMain - native iOS implementation
actual object Secp256k1Instance {
actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray {
// Uses iOS Security framework or native lib
}
}
```
### Class Pattern
```kotlin
// commonMain
expect class BigDecimal {
constructor(value: String)
fun add(other: BigDecimal): BigDecimal
override fun toString(): String
}
// jvmAndroid (works on Android + Desktop)
actual typealias BigDecimal = java.math.BigDecimal
// iosMain
actual class BigDecimal {
private val value: NSDecimalNumber
actual constructor(value: String) {
this.value = NSDecimalNumber(value)
}
// ... implementation
}
```
## Decision Patterns
Ask for each declaration:
1. **Used by 2+ platforms?** → YES (otherwise platform-specific)
2. **Pure Kotlin possible?** → NO (otherwise commonMain)
3. **Varies by platform?** → YES (expect/actual)
4. **JVM-only library?** → NO (otherwise jvmAndroid)
@@ -0,0 +1,332 @@
# Source Set Hierarchy in Amethyst
Visual guide to source set organization with concrete examples from the codebase.
## Hierarchy Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ commonMain │
│ Pure Kotlin, no platform APIs │
│ Examples: │
│ - Nostr event parsing (TextNoteEvent, MetadataEvent) │
│ - Business logic (data validation, crypto algorithms) │
│ - Data models (@Immutable data classes) │
│ Dependencies: kotlin-stdlib, kotlinx-coroutines │
└──────────────────────┬──────────────────────────────────────┘
┌────────────┴────────────┬───────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌───────────────────┐ ┌──────────────┐
│ jvmAndroid │ │ iosMain │ │ Future: │
│ JVM libraries │ │ iOS common │ │ jsMain │
│ Examples: │ │ Examples: │ │ wasmMain │
│ - Jackson JSON │ │ - Platform API │ └──────────────┘
│ - OkHttp HTTP │ │ - Actuals for │
│ - url-detector │ │ crypto/I/O │
│ Dependencies: │ │ Dependencies: │
│ - Jackson │ │ - Platform libs │
│ - OkHttp │ └───────┬───────────┘
└────┬─────────┬───┘ │
│ │ ├─→ iosX64Main (simulator Intel)
│ │ ├─→ iosArm64Main (device ARM64)
│ │ └─→ iosSimulatorArm64Main (Apple Silicon)
▼ ▼
┌──────────┐ ┌───────────┐
│android │ │ jvmMain │
│Main │ │ (Desktop) │
│Examples: │ │ Examples: │
│- Activity│ │- Window │
│- ViewModel│ │- MenuBar │
│- Android │ │- Desktop │
│ APIs │ │ Compose │
│Deps: │ │ Deps: │
│- secp256k│ │- secp256k │
│ 1-android│ │ 1-jvm │
│- androidx│ │- Compose │
│ │ │ Desktop │
└──────────┘ └───────────┘
```
## Dependency Flow
```
Code in commonMain
↓ can use
Nothing (only Kotlin stdlib)
Code in jvmAndroid
↓ can use
commonMain + JVM libraries (Jackson, OkHttp)
Code in androidMain
↓ can use
commonMain + jvmAndroid + Android framework
Code in jvmMain
↓ can use
commonMain + jvmAndroid + JVM + Compose Desktop
Code in iosMain
↓ can use
commonMain + iOS platform APIs
```
## Real Examples from Amethyst
### commonMain - Pure Kotlin
**File:** `quartz/src/commonMain/.../TextNoteEvent.kt`
```kotlin
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseThreadedEvent(...) {
// Pure Kotlin - works everywhere
override fun indexableContent() = "Subject: " + subject() + "\n" + content
}
```
**Why commonMain:**
- Pure Kotlin code
- No platform APIs
- Data class with business logic
- Needed by all platforms
---
### jvmAndroid - JVM Libraries
**File:** `quartz/build.gradle.kts`
```kotlin
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
// Normalizes URLs
api(libs.rfc3986.normalizer)
// Performant Parser of JSONs into Events
api(libs.jackson.module.kotlin)
// Parses URLs from Text
api(libs.url.detector)
// Websockets API
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
}
}
jvmMain { dependsOn(jvmAndroid) } // Desktop gets Jackson, OkHttp
androidMain { dependsOn(jvmAndroid) } // Android gets Jackson, OkHttp
```
**Why jvmAndroid:**
- Jackson, OkHttp are JVM-only libraries
- Works on Android (JVM) and Desktop (JVM)
- Does NOT work on iOS (not JVM) or web (not JVM)
**Usage in code:**
```kotlin
// Can use Jackson in jvmAndroid source set
val mapper = ObjectMapper()
val event = mapper.readValue(json, Event::class.java)
```
---
### androidMain - Android Platform
**File:** `amethyst/src/main/.../MainActivity.kt`
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() // Android API
super.onCreate(savedInstanceState)
setContent { // Compose for Android
AmethystTheme {
val accountStateViewModel: AccountStateViewModel = viewModel()
AccountScreen(accountStateViewModel)
}
}
}
}
```
**Why androidMain:**
- AppCompatActivity is Android framework
- Activity lifecycle Android-specific
- AndroidX libraries (viewModel())
**Dependencies:**
```kotlin
androidMain {
dependsOn(jvmAndroid) // Gets Jackson, OkHttp
dependencies {
implementation(libs.androidx.core.ktx)
api(libs.secp256k1.kmp.jni.android) // Android crypto
}
}
```
---
### jvmMain - Desktop Platform
**File:** `desktopApp/src/jvmMain/.../Main.kt`
```kotlin
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp
)
Window( // Compose Desktop API
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst"
) {
MenuBar { // Desktop-specific
Menu("File") {
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true))
}
}
NavigationRail { ... } // Sidebar
}
}
```
**Why jvmMain:**
- Window, MenuBar, NavigationRail are Compose Desktop
- Keyboard shortcuts desktop paradigm
- Different UX from Android (sidebar vs bottom nav)
**Dependencies:**
```kotlin
jvmMain {
dependsOn(jvmAndroid) // Gets Jackson, OkHttp
dependencies {
implementation(libs.secp256k1.kmp.jni.jvm) // Desktop crypto
implementation(compose.desktop.currentOs)
}
}
```
---
### iosMain - iOS Platform
**File:** `quartz/build.gradle.kts`
```kotlin
iosMain {
dependsOn(commonMain.get())
dependencies {
// iOS platform dependencies
}
}
val iosX64Main by getting { dependsOn(iosMain.get()) }
val iosArm64Main by getting { dependsOn(iosMain.get()) }
val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) }
```
**Why iosMain:**
- iOS platform APIs
- Native crypto (Security framework)
- Different from Android/Desktop
**Architecture targets:**
- iosX64Main: Intel simulator
- iosArm64Main: Device (iPhone, iPad)
- iosSimulatorArm64Main: Apple Silicon simulator
---
## Build Order Matters
**CRITICAL:** jvmAndroid must be defined BEFORE androidMain and jvmMain:
```kotlin
// ✅ CORRECT ORDER
val jvmAndroid = create("jvmAndroid") { ... }
jvmMain { dependsOn(jvmAndroid) }
androidMain { dependsOn(jvmAndroid) }
// ❌ WRONG - Build error
androidMain { dependsOn(jvmAndroid) } // jvmAndroid not defined yet!
val jvmAndroid = create("jvmAndroid") { ... }
```
See comment in quartz/build.gradle.kts:131:
```kotlin
// Must be defined before androidMain and jvmMain
val jvmAndroid = create("jvmAndroid") { ... }
```
## Choosing the Right Source Set
Decision flowchart:
```
Q: Where should this code go?
├─ Pure Kotlin? (no platform APIs)
│ └─ commonMain
├─ JVM library? (Jackson, OkHttp)
│ └─ jvmAndroid
├─ Android API? (Activity, Context)
│ └─ androidMain
├─ Desktop API? (Window, MenuBar)
│ └─ jvmMain
└─ iOS API? (platform.posix, Security)
└─ iosMain
```
## Future: Web/wasm Source Sets
**Not yet implemented**, but structure would be:
```
commonMain
├─→ jsMain (JavaScript/Web)
│ └─ JS-specific: DOM APIs, fetch
└─→ wasmMain (WebAssembly)
└─ wasm-specific: limited APIs
```
**Constraints:**
- Cannot use jvmAndroid (Jackson, OkHttp)
- Cannot use platform.posix
- Must use pure Kotlin or web-compatible libs (ktor, kotlinx.serialization)
## Summary Table
| Source Set | Extends | Can Use | Example Code |
|------------|---------|---------|--------------|
| commonMain | - | Kotlin stdlib only | TextNoteEvent, business logic |
| jvmAndroid | commonMain | JVM libs (Jackson, OkHttp) | JSON parsing, HTTP |
| androidMain | jvmAndroid | Android framework | Activity, ViewModel |
| jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar |
| iosMain | commonMain | iOS platform | Security framework |
| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific |
| iosArm64Main | iosMain | Device (ARM64) | Architecture-specific |
| jsMain | commonMain | JS/DOM | Web (future) |
| wasmMain | commonMain | wasm APIs | WebAssembly (future) |
@@ -0,0 +1,345 @@
# Target Compatibility Guide
Current targets (Android, JVM/Desktop, iOS) and future targets (web, wasm) with constraints.
## Current Primary Targets
### Android (androidMain)
**Status:** ✅ Mature, production-ready
**Runtime:** JVM (Dalvik/ART)
**Available:**
- Android framework (Activity, Context, Intent, etc.)
- AndroidX libraries (ViewModel, Navigation, etc.)
- JVM libraries via jvmAndroid (Jackson, OkHttp)
- Platform-specific crypto: secp256k1-kmp-jni-android
**Constraints:**
- Mobile UX paradigms (bottom navigation, vertical scroll)
- Touch-first interaction
- Limited screen space
- Battery/performance constraints
**Example code:**
```kotlin
// androidMain
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Android-specific lifecycle
}
}
```
---
### JVM / Desktop (jvmMain)
**Status:** ✅ Active development, functional
**Runtime:** JVM
**Available:**
- Pure JVM libraries
- JVM libraries via jvmAndroid (Jackson, OkHttp)
- Compose Desktop (Window, MenuBar, etc.)
- Platform-specific crypto: secp256k1-kmp-jni-jvm
**Constraints:**
- Desktop UX paradigms (sidebar, menus, keyboard shortcuts)
- Keyboard + mouse interaction
- Larger screen space
- Different navigation patterns (no back stack)
**Example code:**
```kotlin
// jvmMain
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "Amethyst"
) {
MenuBar { ... } // Desktop-specific
NavigationRail { ... } // Sidebar
}
}
```
---
### iOS (iosMain + architecture targets)
**Status:** ⚠️ In development, framework configured
**Runtime:** Native iOS
**Source sets:**
- iosMain (common iOS code)
- iosX64Main (Intel simulator)
- iosArm64Main (device - iPhone/iPad)
- iosSimulatorArm64Main (Apple Silicon simulator)
**Available:**
- iOS platform APIs (platform.posix, Foundation, etc.)
- Native crypto (Security framework)
- SwiftUI integration (via KMP framework)
**NOT available:**
- JVM libraries (Jackson, OkHttp)
- jvmAndroid source set
- JVM-specific APIs
**Constraints:**
- Mobile UX (similar to Android)
- Swift/Objective-C interop
- XCFramework distribution
- CocoaPods integration
**Example code:**
```kotlin
// iosMain
actual object Secp256k1Instance {
actual fun signSchnorr(...): ByteArray {
// Use iOS Security framework
}
}
```
**XCFramework setup:**
```kotlin
// quartz/build.gradle.kts
kotlin {
listOf(iosX64(), iosArm64(), iosSimulatorArm64())
.forEach { target ->
target.binaries.framework {
baseName = "quartz-kmpKit"
isStatic = true
}
}
}
```
---
## Future Targets
### Web / JavaScript (jsMain)
**Status:** ❌ Not implemented, consider for future
**Runtime:** JavaScript (browser or Node.js)
**Available:**
- Kotlin/JS stdlib
- JS/DOM APIs
- kotlinx.* libraries (serialization, coroutines, datetime)
- ktor-client (HTTP)
**NOT available:**
- ❌ JVM libraries (Jackson, OkHttp)
- ❌ jvmAndroid source set
- ❌ platform.posix (no file system access like native)
- ❌ Blocking APIs (different async model - JS event loop)
**Constraints:**
- Single-threaded event loop
- No blocking calls
- Different async patterns (Promises, async/await)
- Browser security (CORS, no file system)
**Migration path from current code:**
| Current (jvmAndroid) | Web-compatible alternative |
|---------------------|---------------------------|
| Jackson JSON | kotlinx.serialization |
| OkHttp HTTP | ktor-client |
| java.math.BigDecimal | Kotlin BigDecimal (coming) |
| Blocking I/O | Suspending functions |
**Example migration:**
```kotlin
// Current: jvmAndroid
val mapper = ObjectMapper()
val event = mapper.readValue(json, Event::class.java)
// Future: commonMain (works on web)
val json = Json { ignoreUnknownKeys = true }
val event = json.decodeFromString<Event>(jsonString)
```
---
### WebAssembly (wasmMain)
**Status:** ❌ Not implemented, experimental Kotlin/Wasm
**Runtime:** WebAssembly
**Available:**
- Kotlin/Wasm stdlib
- Limited kotlinx.* libraries
- wasm-specific APIs
**NOT available:**
- ❌ JVM libraries
- ❌ Full platform.posix
- ❌ Many kotlinx libraries (limited wasm support)
**Constraints:**
- Even more limited than JS
- Experimental Kotlin support
- Limited library ecosystem
**Recommendation:** Focus on web (jsMain) first, wasm later.
---
## Cross-Target Compatibility Matrix
| Feature | Android | JVM/Desktop | iOS | Web (JS) | wasm |
|---------|---------|-------------|-----|----------|------|
| Pure Kotlin | ✅ | ✅ | ✅ | ✅ | ✅ |
| kotlinx.coroutines | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| kotlinx.serialization | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| kotlinx.datetime | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| ktor-client | ✅ | ✅ | ✅ | ✅ | ❌ |
| Jackson JSON | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ |
| OkHttp | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ |
| platform.posix | ❌ | ❌ | ✅ | ❌ | ⚠️ |
| Compose Multiplatform | ✅ | ✅ | ⚠️ (experimental) | ⚠️ (experimental) | ❌ |
Legend:
- ✅ Full support
- ⚠️ Limited/experimental
- ❌ Not available
---
## Future-Proofing Recommendations
### For Web Compatibility
**DO:**
- ✅ Use kotlinx.serialization instead of Jackson
- ✅ Use ktor-client instead of OkHttp
- ✅ Use kotlinx.datetime instead of java.time
- ✅ Use suspending functions (non-blocking)
- ✅ Keep business logic in commonMain
**DON'T:**
- ❌ Put JVM libraries in commonMain
- ❌ Use platform.posix for critical features
- ❌ Use blocking I/O
- ❌ Depend on threading (use coroutines)
**Example:**
```kotlin
// ❌ NOT web-compatible
// jvmAndroid
fun parseJson(json: String): Event {
val mapper = ObjectMapper() // Jackson - JVM only
return mapper.readValue(json, Event::class.java)
}
// ✅ Web-compatible
// commonMain
@Serializable
data class Event(...)
fun parseJson(json: String): Event {
return Json.decodeFromString<Event>(json) // Works everywhere
}
```
### Current Migration Priorities
**High priority:** (Needed for web)
1. Migrate Jackson → kotlinx.serialization
2. Migrate OkHttp → ktor-client
3. Move business logic to commonMain
**Medium priority:** (Nice to have)
1. Abstract date/time handling → kotlinx.datetime
2. Remove platform.posix usage where possible
3. Use suspending functions over blocking
**Low priority:** (Future optimization)
1. wasm-specific optimizations
2. Platform-specific performance tuning
---
## Platform-Specific Patterns
### Android vs iOS Differences
| Aspect | Android | iOS |
|--------|---------|-----|
| **Activity/ViewController** | Activity | UIViewController |
| **Navigation** | Compose Navigation | UINavigationController |
| **Lifecycle** | onCreate, onResume, etc. | viewDidLoad, viewWillAppear |
| **Permissions** | Runtime permissions | Info.plist + runtime |
| **Crypto** | secp256k1-android | Security framework |
| **Storage** | Room, SharedPreferences | Core Data, UserDefaults |
### Desktop vs Mobile Differences
| Aspect | Desktop | Mobile |
|--------|---------|--------|
| **Navigation** | Sidebar | Bottom nav |
| **Input** | Keyboard + mouse | Touch |
| **Screen** | Large, landscape | Small, portrait |
| **Windows** | Multi-window | Single app |
| **Shortcuts** | Keyboard shortcuts (Ctrl+N) | None |
| **Menus** | MenuBar | Bottom sheets |
---
## Testing Strategy
### Per-Target Testing
**Android:**
- Unit tests: androidTest
- Instrumented: androidInstrumentedTest
- Device/emulator testing
**Desktop:**
- Unit tests: jvmTest
- Manual desktop app testing
**iOS:**
- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.)
- Simulator/device testing
**Web (future):**
- Unit tests: jsTest
- Browser testing (Selenium, Playwright)
### Shared Testing
**commonTest:**
- Business logic tests
- Pure Kotlin code
- Works on all platforms
```kotlin
// commonTest
class EventParsingTest {
@Test
fun parseTextNoteEvent() {
// Tests run on all platforms
}
}
```
---
## Summary
**Current Focus:** Android, JVM/Desktop, iOS (active development)
**Future Considerations:** Web (requires migration from Jackson/OkHttp)
**Key Decision:** Prefer kotlinx.* libraries over JVM-specific libs for future web compatibility.
@@ -0,0 +1,166 @@
#!/bin/bash
# Suggests KMP library alternatives for JVM-specific dependencies
set -e
PROJECT_ROOT="${1:-.}"
cd "$PROJECT_ROOT"
echo "=== KMP Dependency Suggestions ==="
echo
# Colors
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
SUGGESTIONS_FOUND=0
# Check for Jackson (suggest kotlinx.serialization)
echo "📦 Checking for Jackson JSON..."
if grep -r "jackson" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then
echo -e "${YELLOW}⚠ Found Jackson dependency${NC}"
echo " Current: Jackson (JVM-only)"
echo -e " ${GREEN}Suggest: kotlinx.serialization${NC} (works on all platforms)"
echo
echo " Migration:"
echo " // Remove:"
echo " api(libs.jackson.module.kotlin)"
echo
echo " // Add to commonMain:"
echo " implementation(libs.kotlinx.serialization.json)"
echo
echo " // Code change:"
echo " // Before (Jackson):"
echo " val mapper = ObjectMapper()"
echo " val event = mapper.readValue(json, Event::class.java)"
echo
echo " // After (kotlinx.serialization):"
echo " @Serializable"
echo " data class Event(...)"
echo " val event = Json.decodeFromString<Event>(json)"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using Jackson (or already using kotlinx.serialization)${NC}"
fi
# Check for OkHttp (suggest ktor)
echo
echo "📦 Checking for OkHttp..."
if grep -r "okhttp" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then
echo -e "${YELLOW}⚠ Found OkHttp dependency${NC}"
echo " Current: OkHttp (JVM-only)"
echo -e " ${GREEN}Suggest: ktor-client${NC} (works on all platforms)"
echo
echo " Migration:"
echo " // Remove:"
echo " implementation(libs.okhttp)"
echo
echo " // Add to commonMain:"
echo " implementation(libs.ktor.client.core)"
echo " // Platform-specific engines:"
echo " // androidMain: implementation(libs.ktor.client.android)"
echo " // jvmMain: implementation(libs.ktor.client.cio)"
echo " // iosMain: implementation(libs.ktor.client.darwin)"
echo
echo " // Code change:"
echo " // Before (OkHttp):"
echo " val client = OkHttpClient()"
echo " val request = Request.Builder().url(url).build()"
echo " val response = client.newCall(request).execute()"
echo
echo " // After (ktor):"
echo " val client = HttpClient()"
echo " val response: String = client.get(url)"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using OkHttp (or already using ktor)${NC}"
fi
# Check for java.time (suggest kotlinx.datetime)
echo
echo "📦 Checking for java.time usage..."
if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.time\." >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Found java.time imports${NC}"
echo " Current: java.time (JVM-only)"
echo -e " ${GREEN}Suggest: kotlinx.datetime${NC} (works on all platforms)"
echo
echo " Migration:"
echo " // Add to commonMain:"
echo " implementation(libs.kotlinx.datetime)"
echo
echo " // Code change:"
echo " // Before (java.time):"
echo " import java.time.Instant"
echo " val now = Instant.now()"
echo
echo " // After (kotlinx.datetime):"
echo " import kotlinx.datetime.Clock"
echo " val now = Clock.System.now()"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using java.time (or already using kotlinx.datetime)${NC}"
fi
# Check for java.math.BigDecimal
echo
echo "📦 Checking for java.math.BigDecimal usage..."
if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.math.BigDecimal" >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Found java.math.BigDecimal imports${NC}"
echo " Current: java.math.BigDecimal (JVM-only)"
echo -e " ${BLUE}Note:${NC} KMP BigDecimal not yet in stable kotlinx"
echo
echo " Options:"
echo " 1. Use expect/actual (current approach in quartz)"
echo " 2. Wait for kotlinx.decimal (proposal stage)"
echo " 3. Use third-party KMP library (e.g., bignum)"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using java.math.BigDecimal directly${NC}"
fi
# Check for platform.posix usage
echo
echo "📦 Checking for platform.posix usage..."
if find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "import platform.posix\." >/dev/null 2>&1; then
echo -e "${YELLOW}⚠ Found platform.posix in commonMain${NC}"
echo " Current: platform.posix (native platforms only, not web)"
echo -e " ${GREEN}Suggest:${NC} Abstract file I/O with expect/actual"
echo
echo " For web compatibility:"
echo " - iOS/Native: platform.posix"
echo " - Web: Use kotlinx-io or ktor file APIs"
echo " - Create expect/actual for file operations"
echo
SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1))
else
echo -e "${GREEN}✓ Not using platform.posix in commonMain${NC}"
fi
# Summary
echo
echo "=== Summary ==="
if [ "$SUGGESTIONS_FOUND" -eq 0 ]; then
echo -e "${GREEN}✓ No JVM-specific dependencies found!${NC}"
echo " Your code is ready for web/wasm targets."
else
echo -e "${YELLOW}Found $SUGGESTIONS_FOUND suggestion(s) for KMP alternatives${NC}"
echo
echo "Priority recommendations:"
echo " 1. ${GREEN}High:${NC} Jackson → kotlinx.serialization (enables web support)"
echo " 2. ${GREEN}High:${NC} OkHttp → ktor-client (enables web support)"
echo " 3. ${GREEN}Medium:${NC} java.time → kotlinx.datetime"
echo " 4. ${GREEN}Low:${NC} Consider web compatibility for platform.posix usage"
echo
echo "Resources:"
echo " - kotlinx.serialization: https://github.com/Kotlin/kotlinx.serialization"
echo " - ktor: https://ktor.io/docs/client.html"
echo " - kotlinx.datetime: https://github.com/Kotlin/kotlinx-datetime"
fi
exit 0
@@ -0,0 +1,126 @@
#!/bin/bash
# Validates KMP source set structure and detects common issues
set -e
PROJECT_ROOT="${1:-.}"
cd "$PROJECT_ROOT"
echo "=== Validating KMP Structure ==="
echo
# Colors for output
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
ISSUES_FOUND=0
# Check 1: jvmAndroid defined before androidMain/jvmMain
echo "📋 Checking source set definition order..."
if [ -f "quartz/build.gradle.kts" ]; then
jvmandroid_line=$(grep -n "val jvmAndroid = create" quartz/build.gradle.kts | cut -d: -f1)
android_line=$(grep -n "androidMain {" quartz/build.gradle.kts | cut -d: -f1)
jvm_line=$(grep -n "jvmMain {" quartz/build.gradle.kts | cut -d: -f1)
if [ -n "$jvmandroid_line" ] && [ -n "$android_line" ] && [ -n "$jvm_line" ]; then
if [ "$jvmandroid_line" -lt "$android_line" ] && [ "$jvmandroid_line" -lt "$jvm_line" ]; then
echo -e "${GREEN}${NC} jvmAndroid defined before androidMain and jvmMain"
else
echo -e "${RED}${NC} jvmAndroid must be defined BEFORE androidMain and jvmMain"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
fi
fi
fi
# Check 2: Platform code in commonMain (Android imports)
echo
echo "📋 Checking for platform code in commonMain..."
android_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import android\." || true)
if [ -n "$android_imports_in_common" ]; then
echo -e "${RED}${NC} Found Android imports in commonMain:"
echo "$android_imports_in_common" | sed 's/^/ /'
echo " Fix: Move to androidMain or create expect/actual"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
else
echo -e "${GREEN}${NC} No Android imports in commonMain"
fi
# Check 3: JVM libraries in commonMain (Jackson, OkHttp)
echo
echo "📋 Checking for JVM libraries in commonMain..."
jvm_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import com.fasterxml.jackson\|^import okhttp3\." || true)
if [ -n "$jvm_imports_in_common" ]; then
echo -e "${RED}${NC} Found JVM library imports in commonMain:"
echo "$jvm_imports_in_common" | sed 's/^/ /'
echo " Fix: Move to jvmAndroid or migrate to kotlinx.serialization/ktor"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
else
echo -e "${GREEN}${NC} No JVM library imports in commonMain"
fi
# Check 4: Unmatched expect/actual declarations
echo
echo "📋 Checking expect/actual pairs..."
expect_files=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^expect " || true)
if [ -n "$expect_files" ]; then
for file in $expect_files; do
# Extract declarations
expects=$(grep "^expect \(class\|object\|fun\|interface\)" "$file" | sed 's/expect //' | awk '{print $2}' | sed 's/[({].*$//')
# Check for actuals in platform source sets
for expect_name in $expects; do
actual_count=0
for platform in androidMain jvmMain iosMain; do
platform_dir=$(dirname "$file" | sed "s/commonMain/$platform/")
platform_file="${platform_dir}/$(basename "$file")"
if [ -f "$platform_file" ] && grep -q "actual.*$expect_name" "$platform_file"; then
actual_count=$((actual_count + 1))
fi
done
if [ "$actual_count" -eq 0 ]; then
echo -e "${YELLOW}${NC} No actual implementations found for: $expect_name in $file"
echo " Check: androidMain, jvmMain, iosMain"
ISSUES_FOUND=$((ISSUES_FOUND + 1))
fi
done
done
else
echo -e "${GREEN}${NC} No expect declarations to validate"
fi
# Check 5: Duplicated business logic across platforms
echo
echo "📋 Checking for potential code duplication..."
# This is a heuristic check - look for similar function names in different platform source sets
common_functions=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -h "^fun " | awk '{print $2}' | sed 's/[({<].*$//' | sort -u || true)
if [ -n "$common_functions" ]; then
for func in $common_functions; do
android_count=$(find */src/androidMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l)
jvm_count=$(find */src/jvmMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l)
if [ "$android_count" -gt 0 ] && [ "$jvm_count" -gt 0 ]; then
echo -e "${YELLOW}${NC} Function '$func' found in both androidMain and jvmMain"
echo " Consider: Move to commonMain or jvmAndroid if truly shared"
fi
done
fi
# Summary
echo
echo "=== Summary ==="
if [ "$ISSUES_FOUND" -eq 0 ]; then
echo -e "${GREEN}✓ All checks passed!${NC}"
exit 0
else
echo -e "${RED}✗ Found $ISSUES_FOUND issue(s)${NC}"
echo
echo "Common fixes:"
echo " 1. Platform code in commonMain → Move to androidMain or create expect/actual"
echo " 2. JVM libraries in commonMain → Move to jvmAndroid or migrate to kotlinx.*"
echo " 3. Missing actual implementations → Implement in all target platforms"
echo " 4. Duplicated logic → Move to commonMain or jvmAndroid"
exit 1
fi