About a year ago at my previous company, I got a task from my boss that seemed straightforward at first.
Our Flutter app had a structure where all permissions were requested at once during app initialization — handled inside a single service class that ran on startup. The request was simple: if a user denies camera permission at that point, make sure the permission popup shows up again when they navigate to a screen that actually needs the camera.
So I started working on it.
The Problem
On a Samsung Galaxy device, the behavior was not what I expected.
First request on app launch → popup shows up fine.
User denies it → navigates to the camera screen → tries to request again → nothing happens.
Flutter just returns "requested" with no popup.
const channel = MethodChannel('fida_app/camera_permission');
final result = await channel.invokeMethod('requestPermission', {
'permission': 'android.permission.CAMERA',
});
// second call → result: "requested", no popup
I Thought It Was a Code Problem
My first instinct was to look at the native side. I checked PermissionHandler.kt and thought maybe the onRequestPermissionsResult callback wasn't properly connected to MainActivity. So I tried wiring it up.
That's when something unexpected happened. The app started freezing on the splash screen.
I couldn't pinpoint the exact cause, but my guess — and I later looked into this through ChatGPT — was that ad SDKs like AudienceNetworkAds and AdPopcornSSP were being initialized synchronously inside configureFlutterEngine(), blocking the main thread before Flutter could reach runApp().
The general fix for this kind of issue is to wrap those SDK calls in Handler.post { }, so they run after the Flutter engine has already attached.
// before — blocking the main thread
AudienceNetworkAds.initialize(this)
AdPopcornSSP.init(this, appKey)
// after — deferred asynchronously
Handler(Looper.getMainLooper()).post {
AudienceNetworkAds.initialize(this)
AdPopcornSSP.init(this, appKey)
}
I rolled back the permission code changes and the splash freeze went away. I had gone in to fix one thing and accidentally touched something else entirely.
The Actual Root Cause
I stepped back and thought about it more carefully.
What made this confusing was that the behavior wasn't consistent. Sometimes the popup showed up, sometimes it didn't — with no clear pattern. So naturally I assumed this was something I could control through code. That assumption was wrong.
I dug into this further with ChatGPT to understand the OS-level behavior, and the answer was simpler than I expected.
Android stores permission state at the OS level.
-
If granted → stored as
GRANTED. CallingrequestPermissions()again does nothing. The system just passes through silently. -
If denied → stored as "explicitly denied". Standard Android policy allows another popup on the next request. But on Samsung One UI (Android 13~15), even a single denial can internally flip to
Don't ask againstate — skipping the popup on the very next call.
What looked like inconsistent behavior was actually Samsung's OEM customization policy sitting on top of standard Android rules. It wasn't something I could fix through code — the OS was making the call.
How Android Permission Behavior Changed Across Versions
While looking into this, I also checked how permission behavior evolved across Android API levels — again with the help of ChatGPT to get the details right.
API 22 and below (Android 5.x)
No runtime permissions. Everything was granted at install time.
API 23~29 (Android 6.0~10)
Runtime permissions introduced. On denial, shouldShowRequestPermissionRationale() returns true and the next request shows the popup again. The popup only gets permanently blocked if the user explicitly checks "Don't ask again" before denying.
API 30~32 (Android 11~12L)
One-time permissions added for camera, mic, and location. Choosing "Only this time" means the permission gets revoked when the app goes to background or closes — so the popup appears again on next entry. auto-reset was also introduced here: the OS automatically revokes permissions for apps that haven't been used in a while.
API 33+ (Android 13~)
Same behavior as above. Notification permission is now a separate runtime request (POST_NOTIFICATIONS). Camera permission logic stays the same.
Our app was on targetSdk = 35, minSdk = 23 — so all of these policies applied, including Samsung's OEM behavior on top.
iOS Is Simpler, But Stricter
iOS has one clear rule: deny once, and that's it.
After a user denies, AVCaptureDevice.requestAccess(for: .video) always returns false. The system will never show the popup again no matter how many times you call it.
The only option is to open the settings screen directly.
UIApplication.shared.open(
URL(string: UIApplication.openSettingsURLString)!
)
I didn't know this at first and kept calling requestPermission from Flutter expecting something to happen. Nothing did.
To open the iOS settings screen from Flutter, you need to register a native channel in AppDelegate.swift.
let settingsChannel = FlutterMethodChannel(
name: "fida_app/open_settings",
binaryMessenger: controller.binaryMessenger
)
settingsChannel.setMethodCallHandler { call, result in
if call.method == "openAppSettings",
let url = URL(string: UIApplication.openSettingsURLString),
UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
result(nil)
} else {
result(FlutterMethodNotImplemented)
}
}
So I Changed the Approach
What I originally expected:
Enter camera screen → call requestPermission → popup shows
What I actually built:
Enter camera screen
→ request permission
→ granted → proceed
→ denied
→ show explanation modal
→ try requesting again
→ still denied → open settings directly
Android flow:
static Future<bool> ensureCameraPermission(BuildContext context) async {
String? result = await _channel.invokeMethod<String>(
'requestPermission',
{'permission': 'android.permission.CAMERA'},
);
if (result == 'granted') return true;
bool retry = false;
await showDialog(
context: context,
builder: (_) => AlertModal(
message: 'Camera permission is required to use this feature.',
onConfirm: () async { retry = true; },
),
);
if (retry) {
result = await _channel.invokeMethod<String>(
'requestPermission',
{'permission': 'android.permission.CAMERA'},
);
if (result != 'granted') {
await _settingsChannel.invokeMethod('openAppSettings');
}
}
return result == 'granted';
}
iOS — go straight to settings on denial:
if (Platform.isIOS) {
result = await _iosChannel.invokeMethod<String>('requestPermission');
if (result == 'granted') return true;
await _settingsChannel.invokeMethod('openAppSettings');
return false;
}
The original ask was simple — show the permission popup again when users enter a camera screen after denying at launch. Turns out that’s not something you can freely control through code. The OS decides, and each platform has its own rules.
Understanding how the permission system actually works at the OS level was more useful than any code fix I could have written.
Top comments (0)