60edd473c7
- Updates CLAUDE.md tech stack to current versions (Compose 1.10.3, Kotlin 2.3.20). - Reframes kotlin-multiplatform iOS as mature; adds secp256k1-kmp 0.23.0 references. - Updates desktop-expert Main.kt references (code grew from ~270 to 1341 lines and NavigationRail moved to ui/deck/SinglePaneLayout.kt); replaces obsolete "hardcoded ctrl = true" anti-pattern note with accurate isMacOS branching. - Removes compose-desktop.md (superseded by desktop-expert/). - Adds nostr-expert references: nip19-bech32, event-factory, crypto-and-encryption, large-cache. Adds kotlin-expert/common-utilities, compose-expert/rich-text-parsing, android-expert/image-loading. - New skills: account-state (Account + LocalCache), relay-client (subscriptions, filter assemblers, preloaders), feed-patterns (FeedFilter + FeedViewModel family), auth-signers (NostrSigner across internal / NIP-46 / NIP-55).
1068 lines
28 KiB
Markdown
1068 lines
28 KiB
Markdown
# 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
|
|
- `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
|