Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 300 additions & 0 deletions NOTIFICATION_PERMISSION_ISSUE_ANALYSIS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
# Notification Permission Issue Analysis & Resolution

## Issue Summary

**Problem**: Android notification permission requests were failing on SDK 36 with the following symptoms:
- No permission dialog was shown to users
- Permission requests immediately returned `DeniedAlwaysException`
- Logs showed permission was being auto-denied without user interaction

**Root Cause**: Missing `targetSdk` configuration in the app's build setup

**Impact**: Complete failure of notification permission functionality on Android 13+ devices

---

## Technical Deep Dive

### What Actually Happened

#### 1. **Build Configuration Issue**
```gradle
// BEFORE (Broken)
android {
compileSdk = "35" // ✓ Present
// targetSdk missing ❌
minSdk = "26"
}

// AFTER (Fixed)
android {
compileSdk = "35"
targetSdk = "35" // ✓ Added
minSdk = "26"
}
```

#### 2. **Android's Permission Protection Mechanism**
When `targetSdk` is not specified:
- Android defaults to a very old API level (often API 22 or lower)
- Android 13+ has a security feature that **auto-denies** `POST_NOTIFICATIONS` for apps targeting API < 33
- No dialog is shown - the permission is silently denied
- This appears as immediate `false` return from permission requests

#### 3. **The Investigation Process**

**Step 1: Initial Debugging**
- Added comprehensive logging to trace permission flow
- Suspected race conditions between `BindEffect` and permission requests
- Fixed race conditions but issue persisted

**Step 2: Log Analysis**
```
handlePermissionRequest called for REMOTE_NOTIFICATION
Platform permissions: [android.permission.POST_NOTIFICATIONS]
About to launch permission request...
handlePermissionResult called with: {android.permission.POST_NOTIFICATIONS=false}
```

**Key Insight**: Permission was launching but immediately returning `false` without user interaction

**Step 3: Root Cause Discovery**
- Checked app's build configuration
- Found `compileSdk = "35"` but **no `targetSdk` defined**
- Realized Android was treating the app as targeting old API levels

---

## Android 13+ Notification Permission Behavior

### Critical Requirements

1. **App Must Target API 33+**
```xml
<uses-sdk android:targetSdkVersion="33" />
<!-- or higher -->
```

2. **Permission Must Be Declared**
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```

3. **Manual Request Required**
- Notification permission is **denied by default** on Android 13+
- Apps must explicitly request it via runtime permission API
- No automatic granting or prompting

### Special Characteristics of POST_NOTIFICATIONS

Unlike other Android permissions, `POST_NOTIFICATIONS` has unique behaviors:

1. **`shouldShowRequestPermissionRationale()` is unreliable**
- Often returns `false` even on first denial
- Cannot be used to distinguish "never asked" vs "permanently denied"

2. **Auto-denial for old target SDK**
- Apps targeting < API 33 get automatic denial
- No dialog shown, no user interaction

3. **System-level notification settings**
- Users can disable notifications at system level
- Must be considered in permission state logic

---

## The Complete Solution

### 1. **Build Configuration Fix**

**File: `gradle/libs.versions.toml`**
```toml
[versions]
compileSdk = "35"
targetSdk = "35" # ← Added this line
minSdk = "26"
```

**File: `build.gradle.kts`**
```kotlin
android {
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() // ← Added this line
// ...
}
}
```

### 2. **Permission Manager Enhancements**

#### Race Condition Prevention
```kotlin
// Prevent automatic permission requests in ViewModel init
// Use manual button triggers instead
private fun init() {
// Don't automatically request permission
setState { copy(isInitialized = true) }
}
```

#### Android 13+ Specific Logic
```kotlin
// Special handling for notification permissions
if (permission == Permission.REMOTE_NOTIFICATION &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {

// Use request count instead of shouldShowRequestPermissionRationale
val exception = if (requestCount <= 1) {
DeniedException(permission) // Allow retry
} else {
DeniedAlwaysException(permission) // Redirect to settings
}
}
```

#### State Tracking
```kotlin
// Manual tracking via SharedPreferences
private fun trackPermissionRequest(permission: Permission) {
val prefs = context.getSharedPreferences("permission_prefs", Context.MODE_PRIVATE)
val currentCount = prefs.getInt("notification_request_count", 0)
prefs.edit()
.putBoolean("notification_permission_requested", true)
.putInt("notification_request_count", currentCount + 1)
.apply()
}
```

### 3. **UI Flow Improvements**

```kotlin
// Clear separation of concerns
sealed class Event : ViewEvent {
object Init : Event()
object EnablePushNotifications : Event() // Manual trigger
object ClearPermissionState : Event() // Debug helper
}
```

---

## Key Lessons Learned

### 1. **Always Set targetSdk**
- `targetSdk` is not optional for modern Android development
- Missing `targetSdk` can cause subtle, hard-to-debug issues
- Always match or closely track the latest stable Android API

### 2. **Android 13+ Notification Permissions Are Special**
- Different behavior from other runtime permissions
- Requires API 33+ target to function at all
- `shouldShowRequestPermissionRationale()` is unreliable
- Manual state tracking is necessary

### 3. **Race Conditions in Compose**
- `BindEffect` and `LaunchedEffect` can race during composition
- Don't trigger permission requests automatically in ViewModel init
- Use manual user triggers for better UX and timing control

### 4. **Comprehensive Logging is Essential**
- Added detailed logging throughout permission flow
- Logs revealed the auto-denial behavior
- Logging should be removable for production builds

### 5. **Android Security Model Evolution**
- Each Android version tightens permission requirements
- Old apps may suddenly break on new Android versions
- Regular testing on latest Android versions is crucial

---

## Best Practices Going Forward

### 1. **Build Configuration**
```kotlin
// Always specify all three SDK versions
android {
compileSdk = 35 // Latest for compilation
targetSdk = 35 // Latest stable for features
minSdk = 26 // Minimum supported version
}
```

### 2. **Permission Request Timing**
```kotlin
// ❌ Don't do this
class ViewModel {
private fun init() {
requestPermission() // Race condition risk
}
}

// ✅ Do this instead
class ViewModel {
fun onUserRequestPermission() {
requestPermission() // User-triggered, safe timing
}
}
```

### 3. **Permission State Management**
```kotlin
// Always provide fallback state tracking
private fun getPermissionState(): PermissionState {
return when {
// Check actual permission status first
isGranted() -> PermissionState.Granted

// For notifications, use manual tracking
isNotificationPermission() -> getNotificationState()

// For others, use standard Android APIs
else -> getStandardPermissionState()
}
}
```

### 4. **Error Handling**
```kotlin
try {
permissionManager.requestPermission(Permission.REMOTE_NOTIFICATION)
} catch (e: DeniedException) {
// Show retry option
showPermissionRationale()
} catch (e: DeniedAlwaysException) {
// Redirect to settings
showSettingsRedirect()
} catch (e: RequestCanceledException) {
// Handle cancellation gracefully
logPermissionCanceled()
}
```

---

## Testing Strategy

### 1. **Build Configuration Testing**
- Test app installation on different Android versions
- Verify permission dialogs appear correctly
- Test with different `targetSdk` values

### 2. **Permission Flow Testing**
- First-time permission request
- Permission denial and retry
- Permanent denial and settings redirect
- App reinstallation scenarios

### 3. **Edge Case Testing**
- Activity destruction during permission request
- Multiple rapid permission requests
- Background/foreground state changes

---

## Conclusion

This issue highlights the complexity of Android permission management and the importance of staying current with Android development best practices. The root cause was a missing `targetSdk` configuration, but the investigation revealed several other improvements that make the permission system more robust and user-friendly.

The comprehensive solution addresses not just the immediate issue but also improves the overall permission management architecture to handle future Android changes more gracefully.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[versions]
compileSdk = "35"
targetSdk = "35"
minSdk = "26"
jvm = "17"
gradle = "8.7.1"
Expand Down
1 change: 1 addition & 0 deletions samples/catalog-app-shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ android {

defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = CatalogConfigs.VERSION_CODE
versionName = CatalogConfigs.VERSION_NAME

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class NotificationsSamplesContract {

sealed class Event : ViewEvent {
data object Init : Event()
data object EnablePushNotifications : Event()
}

sealed class Effect : ViewSideEffect
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,43 @@ import com.metacto.catalogapp.presentation.base.BaseViewModel
import com.metacto.catalogapp.presentation.notifications.NotificationsSamplesContract.Effect
import com.metacto.catalogapp.presentation.notifications.NotificationsSamplesContract.Event
import com.metacto.catalogapp.presentation.notifications.NotificationsSamplesContract.State
import com.metacto.core.ui.globalState.models.LoadingType
import com.metacto.core.ui.permissions.IPermissionManager
import com.metacto.core.ui.permissions.enums.Permission
import com.metacto.core.ui.permissions.exceptions.DeniedAlwaysException
import com.metacto.kmm.logger.Logger
import org.koin.core.component.inject

class NotificationsSamplesViewModel : BaseViewModel<State, Event, Effect>() {

override fun setInitialState() = State()

override fun handleEvents(event: Event): Any = when (event) {
Event.Init -> init()
Event.EnablePushNotifications -> handleEnablePushNotificationClick()
}

private fun init() {
// Validate if already initialized
if (currentState.isInitialized) return

// Init

// Init - Don't automatically request permission, let user trigger it manually
// Update the flag
setState { copy(isInitialized = true) }
}

private fun handleEnablePushNotificationClick() = executeCatching(
loadingType = LoadingType.NoLoading,
block = {
permissionManager.requestPermission(Permission.REMOTE_NOTIFICATION)
Logger("handleEnablePushNotificationClick").log("Notifications enabled")
},
shouldShowErrorMessage = { throwable ->
if (throwable is DeniedAlwaysException) {
Logger("handleEnablePushNotificationClick").log("DeniedAlwaysException")
}
false
}
)
}
Loading