Don't make install permission required during onboarding

Closes #10257

We show a warning banner in the extensions list and also rely on the system
alert popup if someone attempts to install without the permission already
granted.

(cherry picked from commit f0710df35696c1f6cf7bb5371dfd6ad91d53fae1)
This commit is contained in:
arkon 2023-12-28 15:21:42 -05:00 committed by Jobobby04
parent ab371a6e50
commit fe53d7b7fb
7 changed files with 97 additions and 42 deletions

View File

@ -26,10 +26,10 @@ class BasePreferences(
fun shownOnboardingFlow() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false) fun shownOnboardingFlow() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false)
enum class ExtensionInstaller(val titleRes: StringResource) { enum class ExtensionInstaller(val titleRes: StringResource, val requiresSystemPermission: Boolean) {
LEGACY(MR.strings.ext_installer_legacy), LEGACY(MR.strings.ext_installer_legacy, true),
PACKAGEINSTALLER(MR.strings.ext_installer_packageinstaller), PACKAGEINSTALLER(MR.strings.ext_installer_packageinstaller, true),
SHIZUKU(MR.strings.ext_installer_shizuku), SHIZUKU(MR.strings.ext_installer_shizuku, false),
PRIVATE(MR.strings.ext_installer_private), PRIVATE(MR.strings.ext_installer_private, false),
} }
} }

View File

@ -1,6 +1,7 @@
package eu.kanade.presentation.browse package eu.kanade.presentation.browse
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -42,12 +43,15 @@ import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.presentation.browse.components.BaseBrowseItem import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.FastScrollLazyColumn import tachiyomi.presentation.core.components.FastScrollLazyColumn
@ -128,11 +132,24 @@ private fun ExtensionContent(
onOpenExtension: (Extension.Installed) -> Unit, onOpenExtension: (Extension.Installed) -> Unit,
onClickUpdateAll: () -> Unit, onClickUpdateAll: () -> Unit,
) { ) {
val context = LocalContext.current
var trustState by remember { mutableStateOf<Extension.Untrusted?>(null) } var trustState by remember { mutableStateOf<Extension.Untrusted?>(null) }
val installGranted = rememberRequestPackageInstallsPermissionState()
FastScrollLazyColumn( FastScrollLazyColumn(
contentPadding = contentPadding + topSmallPaddingValues, contentPadding = contentPadding + topSmallPaddingValues,
) { ) {
if (!installGranted && state.installer?.requiresSystemPermission == true) {
item {
WarningBanner(
textRes = MR.strings.ext_permission_install_apps_warning,
modifier = Modifier.clickable {
context.launchRequestPackageInstallsPermission()
},
)
}
}
state.items.forEach { (header, items) -> state.items.forEach { (header, items) ->
item( item(
contentType = "header", contentType = "header",
@ -387,6 +404,13 @@ private fun ExtensionItemActions(
installStep == InstallStep.Idle -> { installStep == InstallStep.Idle -> {
when (extension) { when (extension) {
is Extension.Installed -> { is Extension.Installed -> {
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(MR.strings.action_settings),
)
}
if (extension.hasUpdate) { if (extension.hasUpdate) {
IconButton(onClick = { onClickItemAction(extension) }) { IconButton(onClick = { onClickItemAction(extension) }) {
Icon( Icon(
@ -395,13 +419,6 @@ private fun ExtensionItemActions(
) )
} }
} }
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(MR.strings.action_settings),
)
}
} }
is Extension.Untrusted -> { is Extension.Untrusted -> {
IconButton(onClick = { onClickItemAction(extension) }) { IconButton(onClick = { onClickItemAction(extension) }) {

View File

@ -11,8 +11,6 @@ import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
@ -35,33 +33,29 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.secondaryItemAlpha import tachiyomi.presentation.core.util.secondaryItemAlpha
internal class PermissionStep : OnboardingStep { internal class PermissionStep : OnboardingStep {
private var installGranted by mutableStateOf(false)
private var notificationGranted by mutableStateOf(false) private var notificationGranted by mutableStateOf(false)
private var batteryGranted by mutableStateOf(false) private var batteryGranted by mutableStateOf(false)
override val isComplete: Boolean override val isComplete: Boolean = true
get() = installGranted
@Composable @Composable
override fun Content() { override fun Content() {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val installGranted = rememberRequestPackageInstallsPermissionState()
DisposableEffect(lifecycleOwner.lifecycle) { DisposableEffect(lifecycleOwner.lifecycle) {
val observer = object : DefaultLifecycleObserver { val observer = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) { override fun onResume(owner: LifecycleOwner) {
installGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
@Suppress("DEPRECATION")
Settings.Secure.getInt(context.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS) != 0
}
notificationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { notificationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
@ -78,31 +72,16 @@ internal class PermissionStep : OnboardingStep {
} }
} }
Column( Column {
modifier = Modifier.padding(vertical = 16.dp),
) {
SectionHeader(stringResource(MR.strings.onboarding_permission_type_required))
PermissionItem( PermissionItem(
title = stringResource(MR.strings.onboarding_permission_install_apps), title = stringResource(MR.strings.onboarding_permission_install_apps),
subtitle = stringResource(MR.strings.onboarding_permission_install_apps_description), subtitle = stringResource(MR.strings.onboarding_permission_install_apps_description),
granted = installGranted, granted = installGranted,
onButtonClick = { onButtonClick = {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.launchRequestPackageInstallsPermission()
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
}
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
context.startActivity(intent)
}, },
) )
Spacer(modifier = Modifier.height(16.dp))
SectionHeader(stringResource(MR.strings.onboarding_permission_type_optional))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permissionRequester = rememberLauncherForActivityResult( val permissionRequester = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),

View File

@ -0,0 +1,41 @@
package eu.kanade.presentation.util
import android.os.Build
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@Composable
fun rememberRequestPackageInstallsPermissionState(): Boolean {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var installGranted by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner.lifecycle) {
val observer = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
installGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
@Suppress("DEPRECATION")
Settings.Secure.getInt(context.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS) != 0
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
return installGranted
}

View File

@ -5,6 +5,7 @@ import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.extension.interactor.GetExtensionsByType import eu.kanade.domain.extension.interactor.GetExtensionsByType
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.components.SEARCH_DEBOUNCE_MILLIS import eu.kanade.presentation.components.SEARCH_DEBOUNCE_MILLIS
@ -34,6 +35,7 @@ import kotlin.time.Duration.Companion.seconds
class ExtensionsScreenModel( class ExtensionsScreenModel(
preferences: SourcePreferences = Injekt.get(), preferences: SourcePreferences = Injekt.get(),
basePreferences: BasePreferences = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(),
private val getExtensions: GetExtensionsByType = Injekt.get(), private val getExtensions: GetExtensionsByType = Injekt.get(),
) : StateScreenModel<ExtensionsScreenModel.State>(State()) { ) : StateScreenModel<ExtensionsScreenModel.State>(State()) {
@ -123,6 +125,10 @@ class ExtensionsScreenModel(
preferences.extensionUpdatesCount().changes() preferences.extensionUpdatesCount().changes()
.onEach { mutableState.update { state -> state.copy(updates = it) } } .onEach { mutableState.update { state -> state.copy(updates = it) } }
.launchIn(screenModelScope) .launchIn(screenModelScope)
basePreferences.extensionInstaller().changes()
.onEach { mutableState.update { state -> state.copy(installer = it) } }
.launchIn(screenModelScope)
} }
fun search(query: String?) { fun search(query: String?) {
@ -198,6 +204,7 @@ class ExtensionsScreenModel(
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val items: ItemGroups = mutableMapOf(), val items: ItemGroups = mutableMapOf(),
val updates: Int = 0, val updates: Int = 0,
val installer: BasePreferences.ExtensionInstaller? = null,
val searchQuery: String? = null, val searchQuery: String? = null,
) { ) {
val isEmpty = items.isEmpty() val isEmpty = items.isEmpty()

View File

@ -9,6 +9,7 @@ import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings
import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.net.toUri import androidx.core.net.toUri
@ -167,3 +168,14 @@ fun Context.isInstalledFromFDroid(): Boolean {
// F-Droid builds typically disable the updater // F-Droid builds typically disable the updater
(!BuildConfig.INCLUDE_UPDATER && !isDevFlavor) (!BuildConfig.INCLUDE_UPDATER && !isDevFlavor)
} }
fun Context.launchRequestPackageInstallsPermission() {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:$packageName")
}
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
startActivity(intent)
}

View File

@ -183,8 +183,6 @@
<string name="onboarding_storage_info">Select a folder where %1$s will store chapter downloads, backups, and more.\n\nA dedicated folder is recommended.\n\nSelected folder: %2$s</string> <string name="onboarding_storage_info">Select a folder where %1$s will store chapter downloads, backups, and more.\n\nA dedicated folder is recommended.\n\nSelected folder: %2$s</string>
<string name="onboarding_storage_action_select">Select a folder</string> <string name="onboarding_storage_action_select">Select a folder</string>
<string name="onboarding_storage_selection_required">A folder must be selected</string> <string name="onboarding_storage_selection_required">A folder must be selected</string>
<string name="onboarding_permission_type_required">Required</string>
<string name="onboarding_permission_type_optional">Optional</string>
<string name="onboarding_permission_install_apps">Install apps permission</string> <string name="onboarding_permission_install_apps">Install apps permission</string>
<string name="onboarding_permission_install_apps_description">To install source extensions.</string> <string name="onboarding_permission_install_apps_description">To install source extensions.</string>
<string name="onboarding_permission_notifications">Notification permission</string> <string name="onboarding_permission_notifications">Notification permission</string>
@ -329,6 +327,7 @@
<string name="ext_info_age_rating">Age rating</string> <string name="ext_info_age_rating">Age rating</string>
<string name="ext_nsfw_short">18+</string> <string name="ext_nsfw_short">18+</string>
<string name="ext_nsfw_warning">Sources from this extension may contain NSFW (18+) content</string> <string name="ext_nsfw_warning">Sources from this extension may contain NSFW (18+) content</string>
<string name="ext_permission_install_apps_warning">Permissions are needed to install extensions. Tap here to grant.</string>
<string name="ext_install_service_notif">Installing extension…</string> <string name="ext_install_service_notif">Installing extension…</string>
<string name="ext_installer_pref">Installer</string> <string name="ext_installer_pref">Installer</string>
<string name="ext_installer_legacy">Legacy</string> <string name="ext_installer_legacy">Legacy</string>