Skip to content

Instantly share code, notes, and snippets.

@m4xp1
Created December 15, 2021 08:29
Show Gist options
  • Select an option

  • Save m4xp1/973086c37636aba219ca253d39bdf11b to your computer and use it in GitHub Desktop.

Select an option

Save m4xp1/973086c37636aba219ca253d39bdf11b to your computer and use it in GitHub Desktop.
Описание концепта пермишенов
class PermissionsManager(
private val application: Application
) {
private val mainThreadScope = CoroutineScope(Job() + Dispatchers.Main)
private var questionHandler: QuestionHandler? = null
private var requestLauncher: ActivityResultLauncher<Array<String>>? = null
private var pendingRequest: Permissions? = null
private val resultFlow = MutableSharedFlow<RequestResult>(
extraBufferCapacity = 1,
onBufferOverflow = DROP_OLDEST,
)
var options: ActivityOptionsCompat? = null
fun attach(
resultCaller: ActivityResultCaller,
lifecycle: Lifecycle,
questionHandler: QuestionHandler? = null
) = lifecycle.addObserver(LifecycleEventObserver { _, event ->
if (event == ON_CREATE) {
this.questionHandler = questionHandler
requestLauncher = resultCaller.registerForActivityResult(
RequestMultiplePermissions(),
::handlePermissionsResult
)
pendingRequest?.let(::request)
} else if (event == ON_DESTROY) {
this.questionHandler = null
requestLauncher?.unregister()
requestLauncher = null
options = null
}
})
fun isGranted(permissions: Permissions): Boolean {
return permissions.list.all {
checkSelfPermission(application, it) == PERMISSION_GRANTED
}
}
fun observe(permissions: Permissions): Flow<RequestResult> {
// обозреваем resultFlow
}
fun request(permissions: Permissions) {
mainThreadScope.launch {
if (requestLauncher == null) {
pendingRequest = permissions
} else {
pendingRequest = null
val rationaleContinuation = RationaleContinuation(permissions)
if (permissions.shouldShowRationale()) {
questionHandler?.showRationale(permissions, rationaleContinuation)
} else {
rationaleContinuation.next()
}
}
}
}
private fun Permissions.shouldShowRationale(): Boolean = questionHandler?.run {
list.any { permission ->
shouldShowRequestPermissionRationale(permission)
}
} ?: false
private fun handlePermissionsResult(permissions: Map<String, Boolean>) {
// отправляем в resultFlow Granted или Denied
}
private fun cancelRequest(permissions: Permissions) {
// отправляем в resultFlow Cancelled
}
private inner class RationaleContinuation(
private val permissions: Permissions
) : Continuation {
override fun next() {
requestLauncher?.launch(permissions.list, options)
}
override fun cancel() {
cancelRequest(permissions)
}
}
}
interface QuestionHandler {
fun shouldShowRequestPermissionRationale(permission: String): Boolean
fun showRationale(permissions: Permissions, continuation: Continuation)
fun showExplanation(permissions: Permissions )
fun showSettings(permissions: Permissions, continuation: Continuation)
}
interface Continuation {
fun next()
fun cancel()
}
interface Permissions {
val list: Array<String>
}
interface DialogPermissions : Permissions {
val rationaleTitle: Int?
val rationaleMessage: Int?
val explanationTitle: Int?
val explanationMessage: Int?
}
sealed class RequestResult {
object Granted : RequestResult()
data class Denied(val permissions: Set<String>) : RequestResult()
object Cancelled : RequestResult()
}

Процесс работы с пермишенами начинается с того что мы определяем класс Permissions который представляет собой Model и хранит в себе данные для отображения диалогов и сами пермишены:

// Сожержимое класса плохо структурировано и тут есть над чем подумать, к примеру rationale и explanation 
// можно добавлять как объекты во внутреннюю коллекцию класса Permissions и тогда можно будет отображать разные  
// типы сообщений для одних и тех же пермишенов. Также при таком варианте отпадет необходимость в создании промежуточных наследников DialogPermissions.
object LocationPermissions : DialogPermissions { // диалог так как хотим показывать их в виде диалога  
  override val rationaleTitle = R.string.rationaleTitle  
  override val rationaleMessage = R.string.rationaleMessage  
  override val explanationTitle = null // не указываем так как не хотим показывать диалог разъяснения  
  override val explanationMessage = null   
  override val list = arrayOf(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION)  
}

Пермишены могут храниться в :shared:permission модуле или быть объявлены непосредственно в фиче, если для нее нужен какой то специфичный текст или вдруг в обосновании мы хотим показывать картинки.

Таким образом разные экраны могут поразному отображать визуально обоснования (в виде диалога / тоста / view на экране), а также для одних и тех же пермишенов может быть разный текст обоснования. К тому же мы можем в разных ситуациях гибко управлять тем, стоит ли нам показать обоснование или же сразу запросить разрешения. Взглянув на гайдлайны можно понять, что данный функционал может быть востребован, так как хорошие запросы пермишинов обычно тесно связанны с контекстом происходящего на экране, а соответственно не всегда нужно реагировать диалогами.

Далее взгляним на фиче рагмент:

class SomeFeatureFragment : Fragment() {  
    
  override fun onCreate(savedInstanceState: Bundle?) {  
    super.onCreate(savedInstanceState)  
    component.permissionsManager.attach(this, lifecycle, dialogQuestionHandler())  
  }  
}

Чтобы не фильтровать ответы от permissionsManager между различными экранами приложения, есть предложение не прокидывать менежджер через зависимости, а создавать его внутри экранного компонента фичи, таким образом у каждого экрана свой permissionsManager.

Далее менеджер аттачится к жизненному циклу и если необходимо показывать диалоги обоснования то указывается QuestionHandler. Что он из себя представляет:

// Апи сыровато, может можно как то подргому пробросить в permissionsManager shouldShowRequestPermissionRationale, а также как то более удачно обработать Continuation.
interface QuestionHandler {  
  
  fun shouldShowRequestPermissionRationale(permission: String): Boolean  
  
  // показать обоснование перед запросом разрешений
  fun showRationale(permissions: Permissions, continuation: Continuation)  
  
  // показать объяснение что функции не доступны так как в разрешении отказано (показывается когда пользователь отказывает в разрешениях либо на диалоге обоснования либо при запросе)
  fun showExplanation(permissions: Permissions )  
  
  // показать запрос на переход в настройки и предоставления разрешения (вызывается когда при запросе происходит сразу же отказ)
  fun showSettings(permissions: Permissions, continuation: Continuation)  
}

Реализация QuestionHandler может находиться в :shared:permissions: и выглядеть следующим образом:

fun Fragment.dialogQuestionHandler(): QuestionHandler {  
  // в реале берем фрагмент менеджер и достаем / создаем PermissionsDialogFragment  
  return PermissionsDialogFragment()  
}  
  
class PermissionsDialogFragment : Fragment(), QuestionHandler {  
  
  override fun showRationale(permissions: Permissions, continuation: Continuation) {  
    // кастим Permissions к DialogPermissions если не скастились значить данный пермишен мы не обрабатывам и диалог не показываем.
    // если скастились то показываем диалог после чего в его калбеках на кнопках вызываем  
  continuation.next() // идем дальше по флоу  
  continuation.cancel() // отменяем запрос  

   // Тут есть проблема с тем чтобы красиво пробросить в GoofyDialog continuation, пока самое простое хранить последний continuation в поле и в колбеке onDialogActionEvent забирать его и занулять поле.
  }  
  
  ....
}

Тут представленна стандартная реализация, когда необходимо показывать диалоги. Но нам ничего не мешает вместо нее указать напрямую SomeFeatureFragment и реализовать на нем QuestionHandler, чтобы реализовать поведение специфичное для экрана и отображать кие то свои диалоги. Также мы можем комбинировать QuestionHandler, нужно всего лишь если мы не хотим обрабатывать какой то конкретный тип пермишенов делегировать его другому QuestionHandler:

class SomeFeatureFragment : Fragment(), QuestionHandler {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    component.permissionsManager.attach(this, lifecycle, this)
  }
  
  override fun showRationale(permissions: Permissions, continuation: Continuation) {
    when(permissions) {
      is DialogPermissions -> dialogQuestionHandler().showRationale(permissions, continuation)
      else -> // show something ui item
    }
  }
}

В принципе на этом работа с пермишенами во View заканчивается. Если подытожить то мы формируем :shared:permission который хранит реализации QuestionHandler и Permissions общих для приложения и завязанных на наших ресурсах / дизайне / архитектуре. Также у нас появляется библиотечный модуль :library:permission в котором храниться сам PermissionsManager и необходимые для его работы классы. Далее фича используя эти два модуля вызывает во фрагменте одну строчку (если не нужно кастомизировать отображение сообщений обоснования):

component.permissionsManager.attach(this, lifecycle, dialogQuestionHandler())

Теперь посмотрим на api со стороны ViewModel:

class SomeViewModel @Inject constructor(  
  private val permissionsManager: PermissionsManager  
) : ViewModel() {  
  
  init {  
    permissionsManager.observe(LocationPermissions)  
      .collect {  
        when (it) {  
          Granted -> // запускаем запрошенный функционал  
          Denied -> // деградируем ui чтобы работать без разрешений для этого отправляем новый стейт во View  
          Cancelled -> // к этому калбеку у меня вопросы, когда такое реально может потребоваться? ведь это синоним Denied.  
        }  
      }.launchIn(viewModelScope)  
  }  
  
  internal fun handleAction(action: TrapMainViewAction) {  
    when (action) {  
      is ShowLocation -> permissionsManager.request(LocationPermissions) // как правило запуск любой функции закрытой пермишенами осуществояется по средством запроса, вся логика по работе функуии размещается в Granted.  
  }  
  }  
}

Также у permissionsManager есть метод fun isGranted(permissions: Permissions): Boolean чтобы синхронно получить текущее состояние пермишенов, это может быть полезно на этапе загрузки экрана чтобы отрисовать начальный стейт.

Пока для ViewModel в PermissionsManager реализован описанный выше вариант. Однако есть мысли представить все немного иначе с точки зрения api, примерно как то так:

class SomeViewModel @Inject constructor(  
  private val permissionsManager: PermissionsManager  
) : ViewModel() {  
  
  val showLocation = permissionsManager.protectedFunction(LocationPermissions)  
      .granted { /* показываем местоположение */ }  
      .denied { /* что то делаем с ui */ }  
      .launchIn(viewModelScope)  
  
  internal fun handleAction(action: TrapMainViewAction) {  
    when (action) {  
      is ShowLocation -> showLocation.invoke()  
    }  
  }  
}

Идея сырая и над api / неймингом стоит поработать, но основной посыл такой что, мы создаем защищенные функции вызов которых приводит к проверке и запросу разрешений если это требуется. После того как разрешения предоставлены функция выполняется. Также у защищенной функции есть возможность проверить разрешена она или нет showLocation.isGranted().

Внутренности модуля :library:permission можно глянуть в этом gist. Решение не закончено, нужно реализовать часть методов, обдумать нейминг и api. Но основная идея в том чтобы добавить гибкости кастомизации и сделать решение независимым от нашего проекта.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment