initial skills
This commit is contained in:
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
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user