Handling Android Permissions in Repository
Nowadays, there are two preferable approaches to building Android architecture. The first one is Google’s Android Architecture, and the second one is Clean Architecture.
An essential part of the architecture is the data layer which includes repositories that provide data to the rest of the application. Repositories include the logic to retrieve data, such as obtaining the current location, retrieving data from another devices via bluetooth, etc. However, since Android 6, things got more complicated. A security enhancement mandates fine grained access control was introduced, requiring asking user permissions to access sensitive information or functions. The mechanism is inconvenient, since it encourages asking for permissions in the view layer, via Activities or Fragments and then retrieve data from the repositories, resulting in logic being spread across different layers.
I’ve implemented a possible solution that helps to work with Android permissions within the repository using Kotlin Coroutines.
The central idea is to employ a global object known as the Permission Checker, serving as a mediator between the Activity and Repository.
Here is the simplified diagram.
In code, the permission checker is represented by the PermissionChecker
interface.
interface PermissionChecker {
suspend fun checkPermissions(vararg permissions: String): Result<Unit>
class PermissionsDeniedException(val permissions: Set<String>) : Exception()
}
It has a checkPermissions
function which accepts a list of permissions and returns a Result<Unit>
— either successful with the Unit
type or with a PermissionsDeniedException
that contains a set of denied permissions.
The current implementation is based on using the Result API — recommended solution by Google, so we need to use either an Activity or Fragment. I will utilize the activity because it’s the fundamental component for the majority of applications.
The snippet below includes the PermissionCheckerActivity
, which serves as the base activity for all other activities. Its main purpose is to be attached to the PermissionChecker
and facilitate the transfer of permission results to it.
abstract class PermissionCheckerActivity : AppCompatActivity() {
private val permissionChecker = get<ResultApiPermissionChecker>()
val resultLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result: Map<String, Boolean> ->
permissionChecker.onPermissionResult(result)
onPermissionResult(result)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
launchOnLifecycleStart {
permissionChecker.attach(this@PermissionCheckerActivity)
}
}
open fun onPermissionResult(result: Map<String, Boolean>) {
// None
}
}
Here, we receive the direct implementation of PermissionChecker
— ResultApiPermissionChecker
, which we obtained from DI, and call the attach
method to include the activity. To ensure that the actual activity is attached to the PermissionChecker
as soon as it becomes visible, the attach
method needs to be called when activity is in Started
state. Also, to align with Google’s recommendation of not overriding lifecycle methods directly, an extension function is utilized in this context.
Additionally, in inherited activities, it is possible to override the onPermissionResult
method and use resultLauncher
property that allows for direct permission checking to organize navigation between activities, for example.
Here is the implementation of the attach
and onPermissionResult
functions. In the case of attach
, the activity is wrapped in a WeakReference
to prevent memory leaks, stored in activityWeakRef
, and onNewActivityCallback
is invoked to notify that a new activity is attached. In the case of onPermissionResult
, the resultCallback
is invoked with the permission result.
private var activityWeakRef: ActivityWeakRef? = null
private var resultCallback: ResultCallback? = null
private var onNewActivityCallback: ActivityCallback? = null
@MainThread
fun attach(activity: PermissionCheckerActivity) {
activityWeakRef = WeakReference(activity)
onNewActivityCallback?.invoke(activity)
}
@MainThread
fun onPermissionResult(result: PermissionResult) {
resultCallback?.invoke(result)
}
Both attach
and onPermissionResult
are annotated with the @MainThread
since both are essentially invoked from the activity, within the main thread, and need to be called in this way for the purpose of synchronization.
The next snippet is the implementation of the checkPermissions
function in ResultApiPermissionChecker
. Function makes a permission request only if any of permissions
are denied; otherwise, it simply returns a success result. Also, we should not worry about the situation when we make a request for a granted permission because the Result API handles that moment and does not show the permission dialog.
The key components in the logic for requesting permissions are the resultLauncher
obtained from the attached activity that is used to ask permissions, and the suspendCancellableCoroutine
function that suspends the function until the PermissionCheckerActivity
invokes the resultCallback
with a permission result.
override suspend fun checkPermissions(vararg permissions: String): Result<Unit> =
if (permissions.any(context::isPermissionDenied)) {
mutex.lock()
try {
val result: PermissionResult = withContext(mainDispatcher) {
var activity: PermissionCheckerActivity? = awaitForStartedActivity()
val result: PermissionResult = suspendCancellableCoroutine {
resultCallback = it::resume
it.invokeOnCancellation { resultCallback = null }
activity?.resultLauncher?.launch(arrayOf(*permissions))
activity = null // Preventing memory leak
}
resultCallback = null
result
}
if (result.all { it.value }) {
Result.success(Unit)
} else {
val deniedPermissions = result
.entries
.asSequence()
.filter { !it.value }
.map { it.key }
.toSet()
Result.failure(PermissionChecker.PermissionsDeniedException(deniedPermissions))
}
} catch (throwable: Throwable) {
Result.failure(throwable)
} finally {
mutex.unlock()
}
} else {
Result.success(Unit)
}
Here we observe that all operations involving the activity and its dependencies are executed on the main thread to ensure synchronization. This is because the activity is a shared resource that can be modified from the main thread. Accessing it from a different thread could result in inconsistent states and potential conflicts.
PermissionCheckerActivity
is received from awaitForStartedActivity
. The activity must be at least in the Started
state; otherwise, there might be a situation¹ where the Result API won't return a permission result. Additionally, the activity only returns a result when it is started.
Here is the implementation of awaitForStartedActivity
private suspend fun awaitForStartedActivity(): PermissionCheckerActivity {
val activity = activityWeakRef?.get()
return if (activity?.isAtLeastStarted == true) {
activity
} else {
val newActivity = suspendCancellableCoroutine {
it.invokeOnCancellation { onNewActivityCallback = null }
onNewActivityCallback = it::resume
}
onNewActivityCallback = null
newActivity
}
}
Lastly, the provided code snippet showcases how the PermissionChecker
is utilized within the location repository to obtain the current location. Here, we do not utilize the ResultApiPermissionChecker
, which is an implementation of the PermissionChecker
interface, because the repository must not have knowledge about whether the permission checker uses an activity or any other specific implementation details.
class LocationRepositoryImpl(
private val context: Context,
private val permissionChecker: PermissionChecker,
private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate,
) : LocationRepository {
override suspend fun getLocation(): Location {
val ex = permissionChecker.checkPermissions(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
).exceptionOrNull()
if ((ex is PermissionChecker.PermissionsDeniedException && ex.permissions.size > 1) ||
(ex != null && ex !is PermissionChecker.PermissionsDeniedException)
) {
throw ex
}
val locationService: LocationManager = context
.getSystemService(LocationManager::class.java)
......
}
}
If you’d like to check out the full code, you can access it via the link below.
[1]: When Activity2, which does not inherit PermissionCheckerActivity
(such as an activity from a library), is launched from Activity1, and permissions are requested within the coroutine scope of Activity1’s ViewModel, returning back to Activity1 after a configuration change (such as rotation, theme change, etc.), the Result API won’t provide a result due to the state persistence within the Result API library.
Please let me know your suggestions and comments.
You can also find me on Linkedin!
Thank you for reading.