Files
amethyst/.claude/skills/android-expert/SKILL.md
T
Claude f94fbe25b1 refactor: put the moved UI in the documented commons layout and fix the audit findings
A review of this branch against commons/ARCHITECTURE.md found the first
moves had kept the app's layer-first paths (commons.ui.screen.loggedIn.<x>)
and put headless code under ui.*. Everything now follows the documented
taxonomy:

  - Feature UI lives in <feature>/ui, named after its quartz package:
    nip51Lists/ui (bookmark groups, people lists, follow packs, interest
    sets), nip52Calendar/ui, nip34Git/ui, nip64Chess/ui, nip23LongContent/ui,
    nip28PublicChat/ui, nip29RelayGroups/ui, nip17Dm/ui, nip56Reports/ui,
    nip72ModCommunities/ui, nipC0CodeSnippets/ui, nipCCGeocaching/ui,
    nipACWebRtcCalls/ui, and concern names where quartz has no NIP (chats,
    marmot, concord, ephemChat, buzz, birdstar, music, cashu, onchain,
    browser, napplet, profile, mediaServers, relays, qrcode, scheduledposts,
    account/ui/login|signup). commons.ui.screen is gone.
  - Nothing under ui.* remains in the commons module. The Route catalog and
    BookmarkType go to model/navigation and model/nip51Lists; the headless
    composer state (AudienceSelection, SplitBuilder, IZapRaiser) to
    model/composer; classifyScannedPayload to qrcode; the scheduled-post
    parsers split out of ScheduledPostMedia into commons scheduledposts,
    leaving only MediaThumbnail in commonsUI. The CLI can now reach them.
  - ARCHITECTURE.md documents ui/feeds and ui/navigation as cross-cutting
    areas and lists the new feature packages.

Duplicates removed:
  - AddButton/RemoveButton existed twice in commonsUI; the unused
    ui/components pair (hard-coded English) is replaced by the localized
    one, which now lives in ui/components/ActionButtons.kt.
  - desktop's own UserDisplayNameLayout was an unused copy of the shared
    one; deleted.
  - Colors.kt merged into Color.kt: empty section headers dropped,
    LightPurple (= Purple200, unused) removed, DefaultPrimary (= Primary80)
    replaced at its one desktop call site.

isLight:
  - The fallback for non-Amethyst backgrounds (desktop's palette, audio
    rooms whose theme overrides the background) is now memoized for the
    last background seen, so those screens pay for luminance() once per
    background instead of on every themed-color read. The comment said
    "single reference comparison", which was no longer true; rewritten.
  - IsLightTest covers both Amethyst schemes across accents, desktop's
    #121212 / #F2F2F7 ramps, themed-room backgrounds and the memo.
  - Behaviour note: NestThemedScope rooms with a dark background used to
    count as light (anything not pure black did), so their gray text,
    placeholders and borders took the light-theme values. They now follow
    the room background's luminance.

Also:
  - Baseline profile: the entries for every class this branch moved
    (theme facades, the Theme.kt members that became AmethystColorScheme /
    MarkdownStyle, routes, the moved composables) are rewritten to the new
    class names so ART still AOT-compiles them. The profile has older stale
    entries from earlier migrations (datasource assemblers, LocalCache,
    okhttp) that need a real regeneration on a device.
  - Two inline fully-qualified Route references in the nests lobby use the
    import instead.
  - GitStatusPill's KDoc said the index read "stays native"; it no longer
    does.
  - The android-expert skill and its navigation reference point to the new
    Route/INav/theme locations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6
2026-09-23 19:33:50 +00:00

28 KiB

name, description
name description
android-expert Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4) Material3 theming and edge-to-edge UI, (5) AndroidManifest.xml and intent filters, (6) Proguard/R8 and APK optimization, (7) Android lifecycle (ViewModel, collectAsStateWithLifecycle), (8) Coil image loading. Delegates shared composables to compose-expert, build files to gradle-expert, and KMP structure to kotlin-multiplatform.

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/RouteNavController.kt  # NavHost-bound route helpers
│   │   │   │   │   └── bottombars/AppBottomBar.kt
│   │   │   │   ├── screen/                  # 80+ screens
│   │   │   │   └── theme/Theme.kt           # AmethystTheme (accent, prefs, window insets)
│   │   │   └── Amethyst.kt                  # Application class
│   │   └── AndroidManifest.xml              # Permissions, intent filters
│   └── androidMain/                         # KMP Android source set
│       └── kotlin/                          # Platform-specific code
└── build.gradle                             # Android config

The @Serializable Route catalog is headless and lives in commons/.../commons/model/navigation/Routes.kt; INav/EmptyNav and the top bars are in commonsUI/.../commons/ui/navigation/. The palettes, ColorScheme.* tokens, sizes and typography are in commonsUI/.../commons/ui/theme/ (markdown styles in its jvmAndroid). Add a route to the commons Routes.kt; wire its destination in the app's AppNavigation.kt.

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

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
  • commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt (route catalog)
  • commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt
  • amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
  • amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt (AmethystTheme)
  • commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/AmethystColorScheme.kt (palettes + ColorScheme.* tokens)
  • 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