New to Claude Skills? Learn how to install them →

vitorpamplona on GitHub

Android Expert

Free

Expert patterns for Android development with Amethyst.

Get this skill

Free · Opens the source repo

What Android Expert does

The Android Expert skill provides a comprehensive framework for developers working on the Amethyst Multiplatform project. It focuses on key Android development patterns, including Compose Navigation, runtime permissions, and Material3 theming. By leveraging this skill, developers can streamline their workflow when dealing with Android-specific tasks, ensuring that they adhere to best practices and utilize the latest features of the Android platform.

This skill covers essential topics such as type-safe navigation, which eliminates the risks associated with using string-based routes by employing Kotlin's serialization features. It also addresses the management of runtime permissions, allowing developers to request permissions contextually using the Accompanist library. The skill emphasizes the importance of a single activity architecture, making it easier to manage the app's lifecycle and state across configuration changes.

In addition to navigation and permissions, the Android Expert skill provides insights into optimizing APK size through Proguard and R8 configurations. It includes guidance on configuring the AndroidManifest.xml file and utilizing platform APIs effectively. This skill is particularly beneficial for developers looking to implement edge-to-edge UI designs and Material3 theming, ensuring a modern and cohesive user experience.

Overall, Android Expert serves as a valuable resource for developers and designers who want to enhance their Android applications using the Amethyst framework. By following the patterns and practices outlined in this skill, users can create robust, maintainable, and user-friendly applications that leverage the full capabilities of the Android platform.

When to use it

Use this skill when developing Android applications with Amethyst, especially when implementing navigation, permissions, or Material3 theming.

When not to use it

This skill may not be suitable for projects that do not utilize the Amethyst framework or for developers working outside of the Android ecosystem.

What you can build with it

Implementing Navigation in an App

Use the Android Expert skill to set up type-safe navigation in your Android app, ensuring smooth transitions between screens.

Managing Permissions Efficiently

Leverage the declarative permission handling patterns provided in this skill to request runtime permissions in a user-friendly manner.

Applying Material3 Theming

Utilize the guidance on Material3 theming to create visually appealing and modern user interfaces that conform to the latest design standards.

How to install Android Expert

View source

1. Install with the skills CLI

npx skills add vitorpamplona/amethyst/android-expert --agent claude-code

2. Or install it manually

Download the skill folder and drop it into ~/.claude/skills/ for all projects, or .claude/skills/ to scope it to one repo. Restart Claude Code so it picks up the new skill.

Anthropic's agentic coding CLI, and the reference implementation of Agent Skills. Drop a skill folder into ~/.claude/skills and Claude Code loads it automatically whenever a task matches the skill's description. Claude Code docs

Inside SKILL.md

Written by vitorpamplona

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+):

// 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):

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:

@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):

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

@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):

@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:

<!-- 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:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()  // Android 15+ immersive UI
        super.onCreate(savedInstanceState)

        setContent {
            AmethystTheme {
                AccountScreen()
            }
        }
    }
}

Theme Configuration

Material3 Color Schemes:

// 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:

@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:

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

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:

@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:

@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:

@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:

@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:

// 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):

@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:

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

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

@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

Build files — the android {} block, the version catalog, dependencies, Proguard/R8, and Desktop packaging — are gradle-expert's domain. Use /gradle-expert instead of duplicating that guidance here. In particular, the app version and the Android versionCode both live in gradle/libs.versions.toml (app / appCode); amethyst/build.gradle.kts reads both from the catalog, so a release bump is a single-file edit.

The one build detail that is genuinely Android-specific — not generic Gradle — is the product-flavor split that ships two channels from one codebase:

flavorDimensions = ["channel"]
productFlavors {
    create("play")   { dimension = "channel" } // Firebase, Google services
    create("fdroid") { dimension = "channel" } // UnifiedPush, open-source only
}

play carries Firebase/Google services; fdroid swaps them for UnifiedPush and open-source alternatives so the F-Droid build stays proprietary-free.

Proguard/R8 rules: see references/proguard-rules.md. APK size analysis: scripts/analyze-apk-size.sh.

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:

// 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

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:

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:

// 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

@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

@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

@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

TaskPattern
NavigatenavController.navigate(Route.Profile(id))
Request PermissionrememberPermissionState().launchPermissionRequest()
Access Contextval context = LocalContext.current
Get Activityval activity = context.getActivity()
Open URLIntent(ACTION_VIEW, Uri.parse(url))
Share TextIntent(ACTION_SEND).putExtra(EXTRA_TEXT, text)
Observe Flowflow.collectAsStateWithLifecycle()
Lifecycle EffectLifecycleResumeEffect { ... }
Handle InsetsModifier.systemBarsPadding()
ThemeMaterialTheme(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
  • references/image-loading.md - Coil 3.x setup, custom fetchers (Blossom/Base64/BlurHash/ThumbHash), MyAsyncImage, RobohashAsyncImage
  • 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

Frequently asked questions about Android Expert

Similar skills