Agent skill

android-expert

Stars 1,494
Forks 189

Install this agent skill to your Project

npx add-skill https://github.com/vitorpamplona/amethyst/tree/main/.claude/skills/android-expert

SKILL.md

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 = 442
        versionName = "1.08.0"

        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 skill

Expand your agent's capabilities with these related and highly-rated skills.

vitorpamplona/amethyst

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

1,494 189
Explore
vitorpamplona/amethyst

quartz-integration

1,494 189
Explore
vitorpamplona/amethyst

desktop-expert

1,494 189
Explore
vitorpamplona/amethyst

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).

1,494 189
Explore
vitorpamplona/amethyst

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

1,494 189
Explore
vitorpamplona/amethyst

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.

1,494 189
Explore

Didn't find tool you were looking for?

Be as detailed as possible for better results