initial skills

This commit is contained in:
nrobi144
2025-12-30 15:18:48 +02:00
parent 258c4e0111
commit f654af9d8a
23 changed files with 10898 additions and 0 deletions
@@ -0,0 +1,615 @@
# Android Navigation Patterns
Complete navigation implementation patterns for Amethyst Android app using Navigation Compose with type safety.
## Type-Safe Routes (Navigation 2.8.0+)
### Route Definitions
```kotlin
// Routes.kt - All 40+ routes in Amethyst
@Serializable
sealed class Route {
// Bottom nav routes
@Serializable object Home : Route()
@Serializable object Messages : Route()
@Serializable object Video : Route()
@Serializable object Discover : Route()
@Serializable object Notification : Route()
// Content routes with parameters
@Serializable data class Profile(val pubkey: String) : Route()
@Serializable data class Note(val id: String) : Route()
@Serializable data class Channel(val id: String) : Route()
@Serializable data class Thread(
val id: String,
val replyTo: String? = null
) : Route()
// New content routes
@Serializable data class NewPost(
val message: String? = null,
val attachment: String? = null,
val replyTo: String? = null
) : Route()
// Settings
@Serializable object Settings : Route()
@Serializable object Security : Route()
@Serializable object Relays : Route()
// Search
@Serializable data class Search(val query: String = "") : Route()
// Media
@Serializable data class Image(val url: String) : Route()
@Serializable data class Video(val url: String) : Route()
}
```
## NavHost Configuration
### Basic Setup
```kotlin
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel,
drawerState: DrawerState
) {
val scope = rememberCoroutineScope()
val nav = remember {
Nav(navController, drawerState, scope)
}
NavHost(
navController = navController,
startDestination = Route.Home,
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) },
popEnterTransition = { fadeIn(animationSpec = tween(200)) },
popExitTransition = { fadeOut(animationSpec = tween(200)) }
) {
// Define routes
composable<Route.Home> {
HomeScreen(accountViewModel, nav)
}
composable<Route.Profile> { backStackEntry ->
val profile = backStackEntry.toRoute<Route.Profile>()
ProfileScreen(
pubkey = profile.pubkey,
accountViewModel = accountViewModel,
nav = nav
)
}
composable<Route.Note> { backStackEntry ->
val note = backStackEntry.toRoute<Route.Note>()
NoteScreen(
noteId = note.id,
accountViewModel = accountViewModel,
nav = nav
)
}
composable<Route.NewPost> { backStackEntry ->
val newPost = backStackEntry.toRoute<Route.NewPost>()
NewPostScreen(
initialMessage = newPost.message,
initialAttachment = newPost.attachment,
replyTo = newPost.replyTo,
accountViewModel = accountViewModel,
onPost = { nav.popBack() }
)
}
}
}
```
### Custom Transitions
```kotlin
composable<Route.Profile>(
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300)
)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(300)
)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(300)
)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(300)
)
}
) { backStackEntry ->
val profile = backStackEntry.toRoute<Route.Profile>()
ProfileScreen(profile.pubkey, accountViewModel, nav)
}
```
## Navigation Manager
### Nav Wrapper Class
```kotlin
class Nav(
val controller: NavHostController,
val drawerState: DrawerState,
val scope: CoroutineScope
) {
/**
* Navigate to a route, closing drawer if open
*/
fun nav(route: Route) {
scope.launch {
if (!controller.popBackStack(route, inclusive = false)) {
controller.navigate(route) {
launchSingleTop = true
}
}
drawerState.close()
}
}
/**
* Navigate with new stack (clear back stack to Home)
*/
fun newStack(route: Route) {
scope.launch {
controller.navigate(route) {
popUpTo(Route.Home) {
inclusive = false
}
launchSingleTop = true
}
drawerState.close()
}
}
/**
* Pop back stack
*/
fun popBack() {
controller.popBackStack()
}
/**
* Pop up to specific route
*/
inline fun <reified T : Route> popUpTo(inclusive: Boolean = false) {
controller.popBackStack<T>(inclusive = inclusive)
}
/**
* Get current route
*/
fun currentRoute(): Route? {
return controller.currentBackStackEntry?.toRoute<Route>()
}
}
```
## Bottom Navigation
### Material3 NavigationBar
```kotlin
@Composable
fun AppBottomBar(
currentRoute: Route?,
nav: Nav
) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
) {
BottomBarRoute.entries.forEach { item ->
NavigationBarItem(
selected = currentRoute?.let { it::class == item.route::class } ?: false,
onClick = { nav.nav(item.route) },
icon = {
Icon(
imageVector = if (currentRoute?.let { it::class == item.route::class } == true) {
item.selectedIcon
} else {
item.unselectedIcon
},
contentDescription = item.label
)
},
label = { Text(item.label) },
alwaysShowLabel = false
)
}
}
}
enum class BottomBarRoute(
val route: Route,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val label: String
) {
HOME(
route = Route.Home,
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home,
label = "Home"
),
MESSAGES(
route = Route.Messages,
selectedIcon = Icons.Filled.Message,
unselectedIcon = Icons.Outlined.Message,
label = "Messages"
),
VIDEOS(
route = Route.Video,
selectedIcon = Icons.Filled.VideoLibrary,
unselectedIcon = Icons.Outlined.VideoLibrary,
label = "Videos"
),
DISCOVER(
route = Route.Discover,
selectedIcon = Icons.Filled.Explore,
unselectedIcon = Icons.Outlined.Explore,
label = "Discover"
),
NOTIFICATIONS(
route = Route.Notification,
selectedIcon = Icons.Filled.Notifications,
unselectedIcon = Icons.Outlined.Notifications,
label = "Notifications"
)
}
```
### Observing Current Route
```kotlin
@Composable
fun MainScreen() {
val navController = rememberNavController()
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackStackEntry?.toRoute<Route>()
Scaffold(
topBar = {
if (shouldShowTopBar(currentRoute)) {
AppTopBar(currentRoute)
}
},
bottomBar = {
if (shouldShowBottomBar(currentRoute)) {
AppBottomBar(currentRoute, nav)
}
}
) { paddingValues ->
AppNavigation(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
fun shouldShowBottomBar(route: Route?): Boolean {
return when (route) {
is Route.Home,
is Route.Messages,
is Route.Video,
is Route.Discover,
is Route.Notification -> true
else -> false
}
}
```
## Navigation Drawer
### Material3 ModalDrawerSheet
```kotlin
@Composable
fun AppDrawer(
drawerState: DrawerState,
nav: Nav,
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
ModalDrawerSheet {
// User profile header
DrawerHeader(accountViewModel.account)
HorizontalDivider()
// Menu items
NavigationDrawerItem(
label = { Text("Home") },
selected = false,
onClick = { nav.nav(Route.Home) },
icon = { Icon(Icons.Default.Home, "Home") }
)
NavigationDrawerItem(
label = { Text("Profile") },
selected = false,
onClick = { nav.nav(Route.Profile(accountViewModel.account.pubkey)) },
icon = { Icon(Icons.Default.Person, "Profile") }
)
NavigationDrawerItem(
label = { Text("Settings") },
selected = false,
onClick = { nav.nav(Route.Settings) },
icon = { Icon(Icons.Default.Settings, "Settings") }
)
HorizontalDivider()
NavigationDrawerItem(
label = { Text("Logout") },
selected = false,
onClick = {
scope.launch {
accountViewModel.logout()
drawerState.close()
}
},
icon = { Icon(Icons.Default.Logout, "Logout") }
)
}
}
```
### Main Scaffold with Drawer
```kotlin
@Composable
fun MainScreen() {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val nav = remember { Nav(navController, drawerState, scope) }
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AppDrawer(drawerState, nav, accountViewModel)
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Amethyst") },
navigationIcon = {
IconButton(
onClick = { scope.launch { drawerState.open() } }
) {
Icon(Icons.Default.Menu, "Menu")
}
}
)
},
bottomBar = { AppBottomBar(currentRoute, nav) }
) { paddingValues ->
AppNavigation(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
}
```
## Deep Link Handling
### Intent Processing
```kotlin
@Composable
fun AppNavigation(
navController: NavHostController,
accountViewModel: AccountViewModel
) {
val activity = LocalContext.current as? Activity
// Handle incoming intents
LaunchedEffect(activity?.intent) {
activity?.intent?.let { intent ->
handleIntent(intent, navController)
}
}
NavHost(navController = navController) {
// Routes...
}
}
fun handleIntent(intent: Intent, navController: NavHostController) {
when (intent.action) {
Intent.ACTION_SEND -> {
// Share text/image
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
val sharedUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
navController.navigate(
Route.NewPost(
message = sharedText,
attachment = sharedUri?.toString()
)
)
}
Intent.ACTION_VIEW -> {
// Deep link
intent.data?.let { uri ->
when (uri.scheme) {
"nostr" -> handleNostrUri(uri, navController)
"https", "http" -> handleWebUri(uri, navController)
}
}
}
}
}
fun handleNostrUri(uri: Uri, navController: NavHostController) {
val path = uri.pathSegments.firstOrNull() ?: return
when {
path.startsWith("npub") -> {
navController.navigate(Route.Profile(path))
}
path.startsWith("note") -> {
navController.navigate(Route.Note(path))
}
path.startsWith("nevent") -> {
// Decode and navigate to event
val eventId = decodeNevent(path)
navController.navigate(Route.Note(eventId))
}
}
}
fun handleWebUri(uri: Uri, navController: NavHostController) {
// Handle web-based deep links
// https://njump.me/npub1...
// https://primal.net/profile/npub1...
when (uri.host) {
"njump.me" -> {
val id = uri.pathSegments.lastOrNull()
if (id?.startsWith("npub") == true) {
navController.navigate(Route.Profile(id))
}
}
"primal.net" -> {
// Parse primal.net URLs
}
}
}
```
### AndroidManifest Intent Filters
```xml
<!-- MainActivity -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostr" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="njump.me" />
<data android:scheme="https" android:host="primal.net" />
<data android:scheme="https" android:host="iris.to" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
</intent-filter>
```
## Nested Navigation
### Tab Navigation Inside Screen
```kotlin
@Composable
fun ProfileScreen(
pubkey: String,
nav: Nav
) {
val nestedNavController = rememberNavController()
Column {
ProfileHeader(pubkey)
// Tab row
TabRow(selectedTabIndex = currentTab) {
Tab(selected = currentTab == 0, onClick = { /* Notes */ })
Tab(selected = currentTab == 1, onClick = { /* Replies */ })
Tab(selected = currentTab == 2, onClick = { /* Likes */ })
}
// Nested NavHost for tabs
NavHost(
navController = nestedNavController,
startDestination = ProfileTab.Notes
) {
composable<ProfileTab.Notes> {
NotesTabContent(pubkey)
}
composable<ProfileTab.Replies> {
RepliesTabContent(pubkey)
}
composable<ProfileTab.Likes> {
LikesTabContent(pubkey)
}
}
}
}
@Serializable
sealed class ProfileTab {
@Serializable object Notes : ProfileTab()
@Serializable object Replies : ProfileTab()
@Serializable object Likes : ProfileTab()
}
```
## Testing Navigation
### Navigation Test Example
```kotlin
@Test
fun testNavigationToProfile() {
val navController = TestNavHostController(
ApplicationProvider.getApplicationContext()
)
composeTestRule.setContent {
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
AppNavigation(navController, accountViewModel)
}
// Navigate to profile
composeTestRule.onNodeWithText("Profile").performClick()
// Verify navigation
val currentRoute = navController.currentBackStackEntry?.toRoute<Route>()
assertTrue(currentRoute is Route.Profile)
}
```
## File Locations
- `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/navigation/Nav.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt`
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt`
@@ -0,0 +1,659 @@
# Android Runtime Permissions
Complete permission handling patterns for Amethyst using Accompanist Permissions library and Android best practices.
## Permission Categories in Amethyst
### Network Permissions (Normal - Auto-granted)
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
```
### Media Permissions (Dangerous - Runtime request)
```xml
<!-- Camera -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Audio -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Storage (version-specific) -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
```
### Notification Permissions (Android 13+)
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```
### Location Permissions
```xml
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
```
### NFC Permissions
```xml
<uses-permission android:name="android.permission.NFC" />
```
### Foreground Service Permissions
```xml
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
```
## Accompanist Permissions Library
### Setup
```gradle
dependencies {
implementation("com.google.accompanist:accompanist-permissions:0.36.0")
}
```
### Single Permission Pattern
```kotlin
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.shouldShowRationale
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraFeature() {
val cameraPermissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
when {
// Permission granted - show feature
cameraPermissionState.status.isGranted -> {
CameraPreview()
}
// Should show rationale - explain why permission is needed
cameraPermissionState.status.shouldShowRationale -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Camera permission is needed to scan QR codes for login",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { cameraPermissionState.launchPermissionRequest() }
) {
Text("Grant Permission")
}
}
}
// First time - request permission
else -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(
onClick = { cameraPermissionState.launchPermissionRequest() }
) {
Icon(Icons.Default.CameraAlt, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Enable Camera")
}
}
}
}
}
```
### Multiple Permissions Pattern
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaUploadFeature() {
val permissionsState = rememberMultiplePermissionsState(
permissions = buildList {
add(Manifest.permission.CAMERA)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
)
when {
// All permissions granted
permissionsState.allPermissionsGranted -> {
MediaUploadUI()
}
// Some permissions need rationale
permissionsState.shouldShowRationale -> {
RationaleDialog(
title = "Permissions Required",
message = "Camera and storage access are needed to upload photos",
onConfirm = {
permissionsState.launchMultiplePermissionRequest()
},
onDismiss = { /* Handle dismissal */ }
)
}
// Request all permissions
else -> {
PermissionRequestScreen(
permissions = permissionsState.permissions,
onRequestPermissions = {
permissionsState.launchMultiplePermissionRequest()
}
)
}
}
}
@Composable
fun RationaleDialog(
title: String,
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Continue")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
```
## Lifecycle-Aware Permission Requests
### Amethyst Pattern: POST_NOTIFICATIONS
**File:** `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt`
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NotificationRegistration(accountViewModel: AccountViewModel) {
val context = LocalContext.current
// Only request on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(
Manifest.permission.POST_NOTIFICATIONS
)
// Register for push notifications when permission is granted
if (notificationPermissionState.status.isGranted) {
LifecycleResumeEffect(
key1 = accountViewModel,
key2 = notificationPermissionState.status.isGranted
) {
val scope = rememberCoroutineScope()
scope.launch(Dispatchers.IO) {
PushNotificationUtils.checkAndInit(
context = context,
accountViewModel = accountViewModel
)
}
onPauseOrDispose {
// Cleanup when composable pauses or disposes
}
}
} else {
// Show prompt to enable notifications
NotificationPermissionPrompt(
onEnableClick = {
notificationPermissionState.launchPermissionRequest()
}
)
}
}
}
@Composable
fun NotificationPermissionPrompt(onEnableClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enable Notifications",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Get notified when someone mentions you or replies to your posts",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onEnableClick,
modifier = Modifier.fillMaxWidth()
) {
Text("Enable Notifications")
}
}
}
}
```
## Permission Best Practices
### 1. Request Contextually
**Bad:**
```kotlin
// Requesting permission on app launch
@Composable
fun AppContent() {
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
LaunchedEffect(Unit) {
// DON'T DO THIS - user doesn't know why
permissionState.launchPermissionRequest()
}
}
```
**Good:**
```kotlin
// Request when user explicitly wants to use camera
@Composable
fun QRScannerButton() {
val permissionState = rememberPermissionState(Manifest.permission.CAMERA)
Button(
onClick = {
if (permissionState.status.isGranted) {
// Open scanner
} else {
// Request permission
permissionState.launchPermissionRequest()
}
}
) {
Text("Scan QR Code")
}
}
```
### 2. Show Rationale
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun LocationFeature() {
val locationPermissionState = rememberPermissionState(
Manifest.permission.ACCESS_COARSE_LOCATION
)
// Always show rationale first for sensitive permissions
if (!locationPermissionState.status.isGranted) {
LocationRationaleCard(
onEnableClick = {
locationPermissionState.launchPermissionRequest()
}
)
} else {
LocationMap()
}
}
@Composable
fun LocationRationaleCard(onEnableClick: () -> Unit) {
Card {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Why location access?",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Location is used for geohashing your posts. " +
"This helps other users discover local content. " +
"Your exact location is never shared.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Row {
OutlinedButton(onClick = { /* Skip */ }) {
Text("Skip")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onEnableClick) {
Text("Enable")
}
}
}
}
}
```
### 3. Handle Permanent Denial
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraFeatureWithSettings() {
val context = LocalContext.current
val cameraPermissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
when {
cameraPermissionState.status.isGranted -> {
CameraPreview()
}
cameraPermissionState.status.shouldShowRationale -> {
// User denied once, show rationale
RationaleDialog(
onConfirm = { cameraPermissionState.launchPermissionRequest() }
)
}
else -> {
// Might be permanently denied - offer settings
PermanentlyDeniedDialog(
onOpenSettings = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
)
}
}
}
@Composable
fun PermanentlyDeniedDialog(onOpenSettings: () -> Unit) {
AlertDialog(
onDismissRequest = { },
title = { Text("Permission Denied") },
text = {
Text(
"Camera permission is required for QR scanning. " +
"Please enable it in Settings."
)
},
confirmButton = {
TextButton(onClick = onOpenSettings) {
Text("Open Settings")
}
},
dismissButton = {
TextButton(onClick = { /* Cancel */ }) {
Text("Cancel")
}
}
)
}
```
### 4. Version-Specific Permissions
```kotlin
@Composable
fun StoragePermissionRequest() {
val permissions = remember {
buildList {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
add(Manifest.permission.READ_MEDIA_IMAGES)
add(Manifest.permission.READ_MEDIA_VIDEO)
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
// Android 10-12: No permission needed for scoped storage
}
else -> {
// Android 9 and below
add(Manifest.permission.READ_EXTERNAL_STORAGE)
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
}
}
if (permissions.isNotEmpty()) {
val permissionsState = rememberMultiplePermissionsState(permissions)
if (!permissionsState.allPermissionsGranted) {
StoragePermissionUI(
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
} else {
MediaPickerUI()
}
} else {
// No permission needed
MediaPickerUI()
}
}
```
## Permission Groups
### Camera + Storage (Media Upload)
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaCaptureFeature() {
val mediaPermissions = buildList {
add(Manifest.permission.CAMERA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.READ_MEDIA_IMAGES)
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
val permissionsState = rememberMultiplePermissionsState(mediaPermissions)
when {
permissionsState.allPermissionsGranted -> {
MediaCaptureUI()
}
else -> {
MediaPermissionScreen(
permissions = permissionsState.permissions,
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
}
}
}
```
### Audio + Storage (Voice Recording)
```kotlin
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun VoiceRecordingFeature() {
val audioPermissions = buildList {
add(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.READ_MEDIA_AUDIO)
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
val permissionsState = rememberMultiplePermissionsState(audioPermissions)
if (permissionsState.allPermissionsGranted) {
AudioRecorderUI()
} else {
AudioPermissionScreen(
onRequest = { permissionsState.launchMultiplePermissionRequest() }
)
}
}
```
## Testing Permissions
### Grant Permission in Tests
```kotlin
@get:Rule
val permissionRule = GrantPermissionRule.grant(
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE
)
@Test
fun testCameraFeatureWithPermission() {
composeTestRule.setContent {
CameraFeature()
}
// Permission is already granted by rule
composeTestRule.onNodeWithText("Take Photo").assertExists()
}
```
### Test Permission Request Flow
```kotlin
@Test
fun testPermissionRequestFlow() {
composeTestRule.setContent {
CameraFeature()
}
// Initially shows permission request button
composeTestRule.onNodeWithText("Enable Camera").assertExists()
// Click to request
composeTestRule.onNodeWithText("Enable Camera").performClick()
// System permission dialog appears (can't test dialog itself)
// Would need UiAutomator to interact with system dialog
}
```
## Permission State Checking
### Check Permission Before Action
```kotlin
fun checkAndRequestCameraPermission(
context: Context,
permissionState: PermissionState,
onGranted: () -> Unit
) {
when {
permissionState.status.isGranted -> {
onGranted()
}
permissionState.status.shouldShowRationale -> {
// Show rationale dialog
}
else -> {
permissionState.launchPermissionRequest()
}
}
}
```
### Manual Permission Check (Non-Compose)
```kotlin
fun hasCameraPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
}
fun requestCameraPermission(activity: ComponentActivity) {
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.CAMERA),
REQUEST_CAMERA_PERMISSION
)
}
// In Activity
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
REQUEST_CAMERA_PERMISSION -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permission granted
} else {
// Permission denied
}
}
}
}
companion object {
private const val REQUEST_CAMERA_PERMISSION = 100
}
```
## File Locations
- `amethyst/src/main/AndroidManifest.xml` - Permission declarations
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt` - Notification permission pattern
- `amethyst/build.gradle` - Accompanist dependency
## Resources
- [Accompanist Permissions Documentation](https://google.github.io/accompanist/permissions/)
- [Android Permissions Guide](https://developer.android.com/guide/topics/permissions/overview)
- [Request Runtime Permissions](https://developer.android.com/training/permissions/requesting)
@@ -0,0 +1,468 @@
# Proguard Rules for Amethyst
Proguard configuration for optimizing and obfuscating Android APK while preserving necessary code.
## What is Proguard/R8?
**R8** is Android's default code shrinker and obfuscator (replaced Proguard in AGP 3.4.0+). It:
- **Shrinks** code by removing unused classes/methods
- **Obfuscates** code by renaming classes/methods to short names
- **Optimizes** code by inlining methods and removing dead code
## Amethyst Proguard Configuration
**File:** `amethyst/proguard-rules.pro`
### Keep Kotlin Metadata
```proguard
# Kotlin metadata is required for reflection
-keep class kotlin.Metadata { *; }
-keep class kotlin.** { *; }
-dontwarn kotlin.**
# Kotlin serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class com.vitorpamplona.**$$serializer { *; }
-keepclassmembers class com.vitorpamplona.** {
*** Companion;
}
-keepclasseswithmembers class com.vitorpamplona.** {
kotlinx.serialization.KSerializer serializer(...);
}
```
### Keep Nostr Event Classes
```proguard
# Nostr events are serialized/deserialized
-keep class com.vitorpamplona.quartz.events.** { *; }
-keep class com.vitorpamplona.quartz.encoders.** { *; }
# Keep event builders
-keep class com.vitorpamplona.quartz.builders.** { *; }
# Keep tag classes
-keep class com.vitorpamplona.quartz.nip01Core.tags.** { *; }
```
### Keep Data Classes
```proguard
# Data classes used in ViewModels and serialization
-keep @kotlinx.serialization.Serializable class * { *; }
# Keep all data classes
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
```
### Keep Compose Classes
```proguard
# Jetpack Compose
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**
# Compose runtime
-keep class androidx.compose.runtime.** { *; }
# Compose UI
-keep class androidx.compose.ui.** { *; }
# Material3
-keep class androidx.compose.material3.** { *; }
# Navigation Compose - Keep serializable routes
-keep class * implements java.io.Serializable { *; }
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
!static !transient <fields>;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
```
### Keep OkHttp/Retrofit
```proguard
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep class okio.** { *; }
# OkHttp WebSockets (for Nostr relays)
-keep class okhttp3.internal.ws.** { *; }
# Retrofit (if used)
-keepattributes Signature
-keepattributes Exceptions
-keep class retrofit2.** { *; }
```
### Keep Jackson (JSON)
```proguard
# Jackson JSON library
-keep class com.fasterxml.jackson.** { *; }
-keep class org.codehaus.** { *; }
-keepclassmembers class * {
@com.fasterxml.jackson.annotation.* <methods>;
}
# Jackson polymorphic types
-keepattributes RuntimeVisibleAnnotations
-keep @com.fasterxml.jackson.annotation.JsonTypeInfo class *
```
### Keep Secp256k1 (Crypto)
```proguard
# Secp256k1 native library
-keep class fr.acinq.secp256k1.** { *; }
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
```
### Keep Tor
```proguard
# Tor library
-keep class com.msopentech.thali.toronionproxy.** { *; }
-dontwarn com.msopentech.thali.toronionproxy.**
```
### Keep ExoPlayer (Media)
```proguard
# ExoPlayer (Media3)
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
-keep class com.google.android.exoplayer2.** { *; }
-dontwarn com.google.android.exoplayer2.**
```
### Keep Coil (Image Loading)
```proguard
# Coil image loading
-keep class coil.** { *; }
-keep class coil3.** { *; }
-dontwarn coil.**
-dontwarn coil3.**
```
### Keep ViewModels
```proguard
# ViewModel classes
-keep class * extends androidx.lifecycle.ViewModel {
<init>();
}
# ViewModel factories
-keep class * extends androidx.lifecycle.ViewModelProvider$Factory {
<init>(...);
}
# Keep ViewModel constructors for reflection
-keepclassmembers class * extends androidx.lifecycle.ViewModel {
<init>(...);
}
```
### Keep Parcelable
```proguard
# Parcelable
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
-keepclassmembers class * implements android.os.Parcelable {
public <fields>;
private <fields>;
}
```
### Keep Enums
```proguard
# Enums
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
```
### Remove Logging (Production)
```proguard
# Remove debug logging in release builds
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
# Keep error/warning logs
-assumenosideeffects class android.util.Log {
public static *** e(...) return false;
public static *** w(...) return false;
}
```
### Keep Crashlytics/Firebase
```proguard
# Firebase Crashlytics
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
# Firebase
-keep class com.google.firebase.** { *; }
-dontwarn com.google.firebase.**
```
## Build Configuration
### Enable R8 in build.gradle
```gradle
android {
buildTypes {
release {
minifyEnabled = true
shrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
minifyEnabled = false
}
}
}
```
### Multiple Proguard Files
```gradle
android {
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
"proguard-quartz.pro", // Library-specific rules
"proguard-compose.pro" // Compose-specific rules
)
}
}
}
```
## Debugging Proguard Issues
### Generate Mapping File
R8 generates `mapping.txt` in `app/build/outputs/mapping/release/`:
```
# Original class name -> Obfuscated name
com.vitorpamplona.amethyst.ui.MainActivity -> a.b.c:
void onCreate(Bundle) -> a
```
### Deobfuscate Stack Traces
```bash
# Using retrace (part of Android SDK)
retrace.sh mapping.txt stacktrace.txt
```
### Enable Proguard Output
```gradle
android {
buildTypes {
release {
proguardFiles(...)
// Generate reports
postprocessing {
proguardFiles = [...]
obfuscate = true
optimizeCode = true
removeUnusedCode = true
}
}
}
}
```
**Output files:**
- `build/outputs/mapping/release/configuration.txt` - All Proguard rules applied
- `build/outputs/mapping/release/mapping.txt` - Obfuscation mappings
- `build/outputs/mapping/release/seeds.txt` - Classes kept by `-keep` rules
- `build/outputs/mapping/release/usage.txt` - Code removed by R8
### Test Release Build
```bash
./gradlew assembleRelease
# Install and test
adb install app/build/outputs/apk/release/app-release.apk
```
## Common Issues
### Issue: NoSuchMethodException at Runtime
**Cause:** Proguard removed or renamed a method used via reflection.
**Solution:**
```proguard
-keep class com.example.YourClass {
public <methods>;
}
```
### Issue: Serialization Fails
**Cause:** Data class fields were renamed.
**Solution:**
```proguard
-keep @kotlinx.serialization.Serializable class * { *; }
-keepclassmembers class * {
@kotlinx.serialization.SerialName <fields>;
}
```
### Issue: Compose Navigation Crashes
**Cause:** @Serializable route classes were obfuscated.
**Solution:**
```proguard
# Keep all route classes
-keep @kotlinx.serialization.Serializable class com.vitorpamplona.amethyst.ui.navigation.routes.** { *; }
```
### Issue: Native Library Crashes
**Cause:** Native method signatures were changed.
**Solution:**
```proguard
-keepclasseswithmembernames class * {
native <methods>;
}
```
## Optimization Tips
### 1. Keep Only What's Necessary
Don't use broad wildcards:
```proguard
# Bad - keeps everything
-keep class com.vitorpamplona.** { *; }
# Good - keeps only specific packages
-keep class com.vitorpamplona.quartz.events.** { *; }
```
### 2. Test Thoroughly
- Test all app features after enabling Proguard
- Test deep links and navigation
- Test serialization/deserialization
- Test external library integrations
### 3. Use AGP's Proguard Analysis
```gradle
android {
buildTypes {
release {
// Generate R8 configuration
android.debug.obsoleteApi = true
}
}
}
```
### 4. Analyze APK Size
```bash
# Build release APK
./gradlew assembleRelease
# Analyze APK with Android Studio
# Build > Analyze APK > Select app-release.apk
```
See `scripts/analyze-apk-size.sh` for automated analysis.
## Product Flavor Specific Rules
### Play Flavor (Firebase)
```proguard
# proguard-play.pro
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
```
### F-Droid Flavor (No Google Services)
```proguard
# proguard-fdroid.pro
# UnifiedPush
-keep class org.unifiedpush.** { *; }
```
**Configure in build.gradle:**
```gradle
android {
flavorDimensions = ["channel"]
productFlavors {
create("play") {
dimension = "channel"
proguardFiles("proguard-play.pro")
}
create("fdroid") {
dimension = "channel"
proguardFiles("proguard-fdroid.pro")
}
}
}
```
## File Locations
- `amethyst/proguard-rules.pro` - Main Proguard rules
- `amethyst/build/outputs/mapping/release/` - Proguard output files
- `amethyst/build.gradle` - Proguard configuration
## Resources
- [Android R8 Documentation](https://developer.android.com/build/shrink-code)
- [Proguard Manual](https://www.guardsquare.com/manual/configuration)
- [Kotlinx Serialization Proguard](https://github.com/Kotlin/kotlinx.serialization#android)