android-expert
Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture.
Best use case
android-expert is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture.
Teams using android-expert should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/android-expert/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How android-expert Compares
| Feature / Agent | android-expert | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# android-expert
Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture.
## When to Use
Auto-invoke when working with:
- Android navigation (Navigation Compose, routes, bottom nav)
- Runtime permissions (camera, notifications, biometric)
- Platform APIs (Intent, Context, Activity)
- Material3 theming and edge-to-edge UI
- Android build configuration (Proguard, APK optimization)
- AndroidManifest.xml configuration
- Android lifecycle (ViewModel, collectAsStateWithLifecycle)
## Core Mental Model
**Single Activity Architecture + Compose Navigation**
```
MainActivity (Single Entry Point)
├── enableEdgeToEdge()
├── AmethystTheme { }
└── NavHost
├── Route.Home → HomeScreen
├── Route.Profile(id) → ProfileScreen
└── Route.Settings → SettingsScreen
Intent Filters (11+)
├── ACTION_MAIN (launcher)
├── ACTION_SEND (share)
├── ACTION_VIEW (deep links: nostr://, https://...)
└── NFC_ACTION_NDEF_DISCOVERED
```
**Key Principles:**
1. **Type-Safe Navigation** - @Serializable routes, no strings
2. **Declarative Permissions** - Request contextually with Accompanist
3. **Edge-to-Edge + Insets** - Scaffold handles system bars
4. **ViewModel + Flow → State** - Survive config changes
5. **Platform Isolation** - Android code in `amethyst/` module or `androidMain/`
## Architecture Overview
### Module Structure
```
amethyst/ # Android app module
├── src/
│ ├── main/
│ │ ├── java/com/vitorpamplona/amethyst/
│ │ │ ├── ui/
│ │ │ │ ├── MainActivity.kt # Entry point
│ │ │ │ ├── navigation/
│ │ │ │ │ ├── AppNavigation.kt # NavHost
│ │ │ │ │ ├── routes/Routes.kt # @Serializable routes
│ │ │ │ │ └── bottombars/AppBottomBar.kt
│ │ │ │ ├── screen/ # 80+ screens
│ │ │ │ └── theme/Theme.kt # Material3 theme
│ │ │ └── Amethyst.kt # Application class
│ │ └── AndroidManifest.xml # Permissions, intent filters
│ └── androidMain/ # KMP Android source set
│ └── kotlin/ # Platform-specific code
└── build.gradle # Android config
```
## 1. Type-Safe Navigation
### Pattern: @Serializable Routes
**Best Practice (Navigation 2.8.0+):**
```kotlin
// Routes.kt - Define all routes with type safety
@Serializable
sealed class Route {
@Serializable object Home : Route()
@Serializable object Search : Route()
@Serializable data class Profile(val pubkey: String) : Route()
@Serializable data class Note(val noteId: String) : Route()
@Serializable data class Thread(val noteId: String) : Route()
}
// AppNavigation.kt - NavHost setup
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel
) {
NavHost(
navController = navController,
startDestination = Route.Home,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
composable<Route.Home> {
HomeScreen(accountViewModel, navController)
}
composable<Route.Profile> { backStackEntry ->
val profile = backStackEntry.toRoute<Route.Profile>()
ProfileScreen(profile.pubkey, accountViewModel, navController)
}
composable<Route.Note> { backStackEntry ->
val note = backStackEntry.toRoute<Route.Note>()
NoteScreen(note.noteId, accountViewModel, navController)
}
}
}
```
### Navigation Manager Pattern
**Amethyst Pattern (`Nav.kt`):**
```kotlin
class Nav(
val controller: NavHostController,
val drawerState: DrawerState,
val scope: CoroutineScope
) {
fun nav(route: Route) {
scope.launch {
controller.navigate(route)
drawerState.close()
}
}
fun newStack(route: Route) {
scope.launch {
controller.navigate(route) {
popUpTo(Route.Home) { inclusive = false }
}
drawerState.close()
}
}
fun popBack() {
controller.popBackStack()
}
}
// Usage in composables
@Composable
fun HomeScreen(nav: Nav) {
Button(onClick = { nav.nav(Route.Profile("npub1...")) }) {
Text("View Profile")
}
}
```
### Bottom Navigation
**Material3 Pattern:**
```kotlin
@Composable
fun AppBottomBar(
selectedRoute: Route,
nav: Nav
) {
NavigationBar {
BottomBarItem.entries.forEach { item ->
NavigationBarItem(
selected = selectedRoute::class == item.route::class,
onClick = { nav.nav(item.route) },
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }
)
}
}
}
enum class BottomBarItem(val route: Route, val icon: ImageVector, val label: String) {
HOME(Route.Home, Icons.Default.Home, "Home"),
MESSAGES(Route.Messages, Icons.Default.Message, "Messages"),
NOTIFICATIONS(Route.Notifications, Icons.Default.Notifications, "Notifications"),
SEARCH(Route.Search, Icons.Default.Search, "Search"),
PROFILE(Route.Profile, Icons.Default.Person, "Profile")
}
```
**Reference:** See `references/android-navigation.md` for complete navigation patterns.
## 2. Runtime Permissions
### Declarative Permission Handling
**Accompanist Pattern (Experimental API):**
```kotlin
import com.google.accompanist.permissions.*
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraFeature() {
val cameraPermissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
when {
cameraPermissionState.status.isGranted -> {
// Permission granted - show camera UI
CameraPreview()
}
cameraPermissionState.status.shouldShowRationale -> {
// Show rationale and request again
Column {
Text("Camera permission is needed to scan QR codes")
Button(
onClick = { cameraPermissionState.launchPermissionRequest() }
) {
Text("Grant Permission")
}
}
}
else -> {
// First time - request permission
Button(
onClick = { cameraPermissionState.launchPermissionRequest() }
) {
Text("Enable Camera")
}
}
}
}
```
### Multiple Permissions
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaUploadFeature() {
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE
)
)
when {
permissionsState.allPermissionsGranted -> {
MediaUploadUI()
}
permissionsState.shouldShowRationale -> {
RationaleDialog(
onConfirm = { permissionsState.launchMultiplePermissionRequest() },
onDismiss = { /* Handle dismissal */ }
)
}
else -> {
PermissionRequestButton(
onClick = { permissionsState.launchMultiplePermissionRequest() }
)
}
}
}
```
### Lifecycle-Aware Permission Requests
**Amethyst Pattern (LoggedInPage.kt):**
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NotificationRegistration(accountViewModel: AccountViewModel) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(
Manifest.permission.POST_NOTIFICATIONS
)
if (notificationPermissionState.status.isGranted) {
LifecycleResumeEffect(
key1 = accountViewModel,
notificationPermissionState.status.isGranted
) {
val scope = rememberCoroutineScope()
scope.launch {
PushNotificationUtils.checkAndInit(
context = context,
accountViewModel = accountViewModel
)
}
onPauseOrDispose {
// Cleanup when paused
}
}
}
}
}
```
### AndroidManifest Permission Declarations
**Key Permissions in Amethyst:**
```xml
<!-- AndroidManifest.xml -->
<manifest>
<!-- Network -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Media -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<!-- Android 13+ Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- NFC -->
<uses-permission android:name="android.permission.NFC" />
<!-- Location (for geohashing) -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Foreground Services -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
</manifest>
```
**Reference:** See `references/android-permissions.md` for complete permission patterns.
## 3. Material3 + Edge-to-Edge
### Edge-to-Edge Setup
**MainActivity Pattern:**
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() // Android 15+ immersive UI
super.onCreate(savedInstanceState)
setContent {
AmethystTheme {
AccountScreen()
}
}
}
}
```
### Theme Configuration
**Material3 Color Schemes:**
```kotlin
// theme/Theme.kt
private val DarkColorPalette = darkColorScheme(
primary = Purple200,
secondary = Teal200,
tertiary = Pink80,
background = Color.Black,
surface = Color.Black,
onPrimary = Color.White,
onSecondary = Color.Black,
onBackground = Color.White,
onSurface = Color.White
)
private val LightColorPalette = lightColorScheme(
primary = Purple500,
secondary = Teal700,
tertiary = Pink40,
background = Color.White,
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.Black,
onSurface = Color.Black
)
@Composable
fun AmethystTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorPalette else LightColorPalette
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
```
### Scaffold with Insets
**Handling System Bars:**
```kotlin
@Composable
fun MainScreen(navController: NavHostController) {
val currentRoute by navController.currentBackStackEntryAsState()
Scaffold(
topBar = { AppTopBar(currentRoute) },
bottomBar = { AppBottomBar(currentRoute, navController) },
floatingActionButton = { NewPostFab() }
) { innerPadding ->
// Scaffold automatically handles system bar insets
NavHost(
navController = navController,
modifier = Modifier.padding(innerPadding)
) {
// Routes...
}
}
}
```
**Custom Inset Handling:**
```kotlin
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.systemBarsPadding
@Composable
fun CustomEdgeToEdgeScreen() {
Box(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding() // Add padding for system bars
) {
// Content draws edge-to-edge with safe padding
}
}
```
## 4. ViewModel + Lifecycle
### ViewModel Pattern
**Standard Structure (80+ ViewModels in Amethyst):**
```kotlin
class FeedViewModel(
private val accountStateViewModel: AccountStateViewModel
) : ViewModel() {
private val _feedState = MutableStateFlow<FeedState>(FeedState.Loading)
val feedState: StateFlow<FeedState> = _feedState.asStateFlow()
init {
loadFeed()
}
fun loadFeed() {
viewModelScope.launch {
_feedState.value = FeedState.Loading
try {
val posts = repository.getFeed()
_feedState.value = FeedState.Success(posts)
} catch (e: Exception) {
_feedState.value = FeedState.Error(e.message)
}
}
}
fun refresh() {
loadFeed()
}
}
sealed class FeedState {
object Loading : FeedState()
data class Success(val posts: List<Post>) : FeedState()
data class Error(val message: String?) : FeedState()
}
```
### Compose Integration
**collectAsStateWithLifecycle Pattern:**
```kotlin
@Composable
fun FeedScreen(
feedViewModel: FeedViewModel = viewModel()
) {
val feedState by feedViewModel.feedState.collectAsStateWithLifecycle()
when (feedState) {
is FeedState.Loading -> {
LoadingIndicator()
}
is FeedState.Success -> {
val posts = (feedState as FeedState.Success).posts
LazyColumn {
items(posts) { post ->
PostCard(post)
}
}
}
is FeedState.Error -> {
ErrorScreen(
message = (feedState as FeedState.Error).message,
onRetry = { feedViewModel.refresh() }
)
}
}
}
```
### Lifecycle Effects
**LifecycleResumeEffect Pattern:**
```kotlin
@Composable
fun ChatScreen(chatViewModel: ChatViewModel) {
LifecycleResumeEffect(key1 = chatViewModel) {
// Called when composable resumes (onResume)
chatViewModel.connectToRelay()
onPauseOrDispose {
// Called when composable pauses (onPause) or disposes
chatViewModel.disconnectFromRelay()
}
}
}
```
**DisposableEffect for Cleanup:**
```kotlin
@Composable
fun VideoPlayer(videoUrl: String) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(videoUrl))
prepare()
}
}
DisposableEffect(videoUrl) {
onDispose {
exoPlayer.release()
}
}
AndroidView(
factory = { PlayerView(it).apply { player = exoPlayer } }
)
}
```
## 5. Platform APIs
### Activity & Context Access
**LocalContext Pattern:**
```kotlin
@Composable
fun ShareButton(text: String) {
val context = LocalContext.current
Button(
onClick = {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "Share via"))
}
) {
Text("Share")
}
}
```
**Activity Reference:**
```kotlin
// WindowUtils.kt pattern
@Composable
fun getActivity(): Activity? = LocalContext.current.getActivity()
tailrec fun Context.getActivity(): ComponentActivity =
when (this) {
is ComponentActivity -> this
is ContextWrapper -> baseContext.getActivity()
else -> throw IllegalStateException("Context not an Activity")
}
// Usage
@Composable
fun FullscreenToggle() {
val activity = getActivity()
Button(
onClick = {
activity?.window?.setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
)
}
) {
Text("Go Fullscreen")
}
}
```
### Intent Handling
**Deep Links (AppNavigation.kt pattern):**
```kotlin
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel
) {
val activity = LocalContext.current as? Activity
LaunchedEffect(activity?.intent) {
activity?.intent?.let { intent ->
when (intent.action) {
Intent.ACTION_SEND -> {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
val sharedImage = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
navController.navigate(
Route.NewPost(message = sharedText, attachment = sharedImage.toString())
)
}
Intent.ACTION_VIEW -> {
val uri = intent.data
when (uri?.scheme) {
"nostr" -> handleNostrUri(uri, navController)
"https", "http" -> handleWebUri(uri, navController)
}
}
}
}
}
NavHost(navController = navController) {
// Routes...
}
}
fun handleNostrUri(uri: Uri, navController: NavHostController) {
// nostr:npub1... -> Profile
// nostr:note1... -> Note
// nostr:nevent1... -> Event
when {
uri.path?.startsWith("npub") == true -> {
navController.navigate(Route.Profile(uri.path!!))
}
uri.path?.startsWith("note") == true -> {
navController.navigate(Route.Note(uri.path!!))
}
}
}
```
### File Sharing with FileProvider
**ShareHelper Pattern:**
```kotlin
fun shareImage(context: Context, imageUri: Uri) {
try {
// Get file from cache
val cachedFile = getCachedFile(context, imageUri)
// Create content URI via FileProvider
val contentUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
cachedFile
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "image/*"
putExtra(Intent.EXTRA_STREAM, contentUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(shareIntent, "Share Image"))
} catch (e: Exception) {
Toast.makeText(context, "Failed to share: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
```
**FileProvider Configuration (AndroidManifest.xml):**
```xml
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
```
### Activity Results
**External Signer Integration (Amethyst pattern):**
```kotlin
@Composable
fun SignerIntegration(accountViewModel: AccountViewModel) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let { data ->
accountViewModel.account.signer.newResponse(data)
}
}
}
Button(
onClick = {
val signerIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("nostrsigner:...")
}
launcher.launch(signerIntent)
}
) {
Text("Sign with External App")
}
}
```
## 6. Build Configuration
### Android Block
**build.gradle (Amethyst pattern):**
```gradle
android {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = 36
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
versionCode = 439
versionName = "1.07.3"
vectorDrawables {
useSupportLibrary = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true // Enable BuildConfig access
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
packaging {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
// Product flavors for Play Store vs F-Droid
flavorDimensions = ["channel"]
productFlavors {
create("play") {
dimension = "channel"
// Firebase, Google services
}
create("fdroid") {
dimension = "channel"
// UnifiedPush, open-source alternatives
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
}
}
```
### Dependencies
**Key Android Dependencies:**
```gradle
dependencies {
// Compose BOM
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
// Navigation
implementation(libs.androidx.navigation.compose)
// Lifecycle
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Activity
implementation(libs.androidx.activity.compose)
// Accompanist
implementation(libs.accompanist.permissions)
// Shared module
implementation(project(":commons"))
implementation(project(":quartz"))
}
```
### Proguard Rules
**Common Rules for Amethyst:**
```proguard
# Keep Kotlin metadata
-keep class kotlin.Metadata { *; }
# Keep Nostr event classes
-keep class com.vitorpamplona.quartz.events.** { *; }
# Keep serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
# OkHttp
-dontwarn okhttp3.**
-keep class okhttp3.** { *; }
# Compose
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**
```
**Reference:** See `references/proguard-rules.md` for complete Proguard configuration.
### APK Optimization
**Reference:** See `scripts/analyze-apk-size.sh` for APK size analysis.
## 7. KMP Android Source Sets
### Android Module Layout
**Amethyst Structure:**
```
amethyst/
├── src/
│ ├── main/ # Standard Android
│ │ ├── java/com/.../ # Compose UI code
│ │ ├── res/ # Android resources
│ │ └── AndroidManifest.xml
│ └── androidMain/ # KMP Android source set (if needed)
│ └── kotlin/ # Platform-specific utilities
└── build.gradle
```
**Platform-Specific Code:**
```kotlin
// commons/src/androidMain/kotlin/Platform.android.kt
actual fun openExternalUrl(url: String, context: Any) {
val ctx = context as Context
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
ctx.startActivity(intent)
}
actual fun shareText(text: String, context: Any) {
val ctx = context as Context
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}
ctx.startActivity(Intent.createChooser(intent, "Share"))
}
```
### Build Configuration for KMP
```gradle
kotlin {
androidTarget {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
}
}
}
android {
sourceSets {
// Link androidMain source set
getByName("main") {
manifest.srcFile("src/main/AndroidManifest.xml")
java.srcDirs("src/main/java", "src/androidMain/kotlin")
}
}
}
```
## Common Patterns
### 1. Single Activity Architecture
**All screens in one activity, navigation via Compose:**
```kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
AmethystTheme {
val accountViewModel: AccountStateViewModel = viewModel()
AccountScreen(accountViewModel)
}
}
}
override fun onResume() {
super.onResume()
DEFAULT_MUTED_SETTING.value = true
}
override fun onPause() {
super.onPause()
LanguageTranslatorService.clear()
}
}
```
### 2. Configuration Changes
**ViewModels survive rotation:**
```kotlin
// ViewModel persists across config changes
@Composable
fun ProfileScreen(
profileViewModel: ProfileViewModel = viewModel()
) {
val profile by profileViewModel.profile.collectAsStateWithLifecycle()
// UI rebuilds on rotation, but ViewModel data persists
ProfileContent(profile)
}
```
### 3. Resource Access
```kotlin
@Composable
fun LocalizedButton() {
val context = LocalContext.current
Button(
onClick = {
val message = context.getString(R.string.button_clicked)
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
) {
Text(stringResource(R.string.button_label))
}
}
```
## Testing Android Components
### Navigation Testing
```kotlin
@Test
fun testNavigationToProfile() {
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
composeTestRule.setContent {
navController.navigatorProvider.addNavigator(ComposeNavigator())
AppNavigation(navController, accountViewModel)
}
composeTestRule.onNodeWithText("Profile").performClick()
assertEquals(
Route.Profile::class,
navController.currentBackStackEntry?.destination?.route::class
)
}
```
### Permission Testing
```kotlin
@Test
fun testPermissionRequest() {
val scenario = launchActivity<MainActivity>()
scenario.onActivity { activity ->
// Grant permission via UiAutomator
grantPermissionViaUi(Manifest.permission.CAMERA)
}
composeTestRule.onNodeWithText("Camera Ready").assertExists()
}
```
## Anti-Patterns to Avoid
1. **String-based navigation** - Use type-safe @Serializable routes
2. **Requesting permissions eagerly** - Request contextually before feature use
3. **Ignoring edge-to-edge** - Handle insets properly with Scaffold
4. **Using GlobalScope** - Use viewModelScope or rememberCoroutineScope
5. **Not handling config changes** - Use ViewModel + collectAsStateWithLifecycle
6. **Hardcoded system bar heights** - Use WindowInsets APIs
7. **Blocking main thread** - Use viewModelScope.launch(Dispatchers.IO)
## Quick Reference
| Task | Pattern |
|------|---------|
| **Navigate** | `navController.navigate(Route.Profile(id))` |
| **Request Permission** | `rememberPermissionState().launchPermissionRequest()` |
| **Access Context** | `val context = LocalContext.current` |
| **Get Activity** | `val activity = context.getActivity()` |
| **Open URL** | `Intent(ACTION_VIEW, Uri.parse(url))` |
| **Share Text** | `Intent(ACTION_SEND).putExtra(EXTRA_TEXT, text)` |
| **Observe Flow** | `flow.collectAsStateWithLifecycle()` |
| **Lifecycle Effect** | `LifecycleResumeEffect { ... }` |
| **Handle Insets** | `Modifier.systemBarsPadding()` |
| **Theme** | `MaterialTheme(colorScheme = ...) { }` |
## File Locations
**Key Android Files:**
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt`
- `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/theme/Theme.kt`
- `amethyst/src/main/AndroidManifest.xml`
- `amethyst/build.gradle`
## Additional Resources
- `references/android-navigation.md` - Complete navigation patterns and examples
- `references/android-permissions.md` - Permission handling patterns
- `references/proguard-rules.md` - Proguard configuration
- `scripts/analyze-apk-size.sh` - APK size optimization script
## When NOT to Use
- Desktop-specific features → Use `desktop-expert` skill
- iOS-specific features → Use `ios-expert` skill
- Shared KMP code → Use `kotlin-multiplatform` skill
- Nostr protocol → Use `nostr-expert` skill
- Compose UI components → Use `compose-expert` skillRelated Skills
nostr-expert
Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
kotlin-expert
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.
gradle-expert
Build optimization, dependency resolution, and multi-module KMP troubleshooting for AmethystMultiplatform. Use when working with: (1) Gradle build files (build.gradle.kts, settings.gradle), (2) Version catalog (libs.versions.toml), (3) Build errors and dependency conflicts, (4) Module dependencies and source sets, (5) Desktop packaging (DMG/MSI/DEB), (6) Build performance optimization, (7) Proguard/R8 configuration, (8) Common KMP + Android Gradle issues (Compose conflicts, secp256k1 JNI variants, source set problems).
Desktop Expert
Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.
compose-expert
Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector icons, or determining whether to share UI in commonMain vs keep platform-specific. Delegates navigation to android-expert/desktop-expert. Complements kotlin-expert (handles Kotlin language aspects of state/annotations).
Amethyst Builder Skill
Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, and distribute your own version.
quartz-integration
Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects.
kotlin-multiplatform
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-coroutines
Advanced Kotlin coroutines patterns for AmethystMultiplatform. Use when working with: (1) Structured concurrency (supervisorScope, coroutineScope), (2) Advanced Flow operators (flatMapLatest, combine, merge, shareIn, stateIn), (3) Channels and callbackFlow, (4) Dispatcher management and context switching, (5) Exception handling (CoroutineExceptionHandler, SupervisorJob), (6) Testing async code (runTest, Turbine), (7) Nostr relay connection pools and subscriptions, (8) Backpressure handling in event streams. Delegates to kotlin-expert for basic StateFlow/SharedFlow patterns. Complements nostr-expert for relay communication.
find-non-lambda-logs
Use when auditing or migrating Log calls to lambda overloads, after adding new logging, or checking for string interpolation in Log.d/i/w/e calls that waste allocations when the log level is filtered out
find-missing-translations
Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language
computer-vision-expert
SOTA Computer Vision Expert (2026). Specialized in YOLO26, Segment Anything 3 (SAM 3), Vision Language Models, and real-time spatial analysis.