initial skills
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user