From f0710df35696c1f6cf7bb5371dfd6ad91d53fae1 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 28 Dec 2023 15:21:42 -0500 Subject: [PATCH] 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. --- .../eu/kanade/domain/base/BasePreferences.kt | 10 ++--- .../presentation/browse/ExtensionsScreen.kt | 31 ++++++++++---- .../more/onboarding/PermissionStep.kt | 35 ++++------------ .../kanade/presentation/util/Permissions.kt | 41 +++++++++++++++++++ .../browse/extension/ExtensionsScreenModel.kt | 7 ++++ .../util/system/ContextExtensions.kt | 12 ++++++ .../commonMain/resources/MR/base/strings.xml | 3 +- 7 files changed, 97 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/util/Permissions.kt diff --git a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt index af23735e9..24ad9a1b7 100644 --- a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt @@ -26,10 +26,10 @@ class BasePreferences( fun shownOnboardingFlow() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false) - enum class ExtensionInstaller(val titleRes: StringResource) { - LEGACY(MR.strings.ext_installer_legacy), - PACKAGEINSTALLER(MR.strings.ext_installer_packageinstaller), - SHIZUKU(MR.strings.ext_installer_shizuku), - PRIVATE(MR.strings.ext_installer_private), + enum class ExtensionInstaller(val titleRes: StringResource, val requiresSystemPermission: Boolean) { + LEGACY(MR.strings.ext_installer_legacy, true), + PACKAGEINSTALLER(MR.strings.ext_installer_packageinstaller, true), + SHIZUKU(MR.strings.ext_installer_shizuku, false), + PRIVATE(MR.strings.ext_installer_private, false), } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt index 3b63e86fd..c9c83ab45 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt @@ -1,6 +1,7 @@ package eu.kanade.presentation.browse import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -42,12 +43,15 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.StringResource import eu.kanade.presentation.browse.components.BaseBrowseItem import eu.kanade.presentation.browse.components.ExtensionIcon +import eu.kanade.presentation.components.WarningBanner 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.InstallStep import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel import eu.kanade.tachiyomi.util.system.LocaleHelper +import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.FastScrollLazyColumn import tachiyomi.presentation.core.components.material.PullRefresh @@ -127,11 +131,24 @@ private fun ExtensionContent( onOpenExtension: (Extension.Installed) -> Unit, onClickUpdateAll: () -> Unit, ) { + val context = LocalContext.current var trustState by remember { mutableStateOf(null) } + val installGranted = rememberRequestPackageInstallsPermissionState() FastScrollLazyColumn( 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) -> item( contentType = "header", @@ -384,6 +401,13 @@ private fun ExtensionItemActions( installStep == InstallStep.Idle -> { when (extension) { is Extension.Installed -> { + IconButton(onClick = { onClickItemAction(extension) }) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(MR.strings.action_settings), + ) + } + if (extension.hasUpdate) { IconButton(onClick = { onClickItemAction(extension) }) { Icon( @@ -392,13 +416,6 @@ private fun ExtensionItemActions( ) } } - - IconButton(onClick = { onClickItemAction(extension) }) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(MR.strings.action_settings), - ) - } } is Extension.Untrusted -> { IconButton(onClick = { onClickItemAction(extension) }) { diff --git a/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt b/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt index e7e3ec598..79e45159f 100644 --- a/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt +++ b/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt @@ -11,8 +11,6 @@ import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.material.icons.Icons import androidx.compose.material.icons.filled.Check @@ -35,33 +33,29 @@ import androidx.compose.ui.unit.dp import androidx.core.content.getSystemService import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState +import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission import tachiyomi.i18n.MR import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.secondaryItemAlpha internal class PermissionStep : OnboardingStep { - private var installGranted by mutableStateOf(false) private var notificationGranted by mutableStateOf(false) private var batteryGranted by mutableStateOf(false) - override val isComplete: Boolean - get() = installGranted + override val isComplete: Boolean = true @Composable override fun Content() { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val installGranted = rememberRequestPackageInstallsPermissionState() + 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 - } notificationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED @@ -78,31 +72,16 @@ internal class PermissionStep : OnboardingStep { } } - Column( - modifier = Modifier.padding(vertical = 16.dp), - ) { - SectionHeader(stringResource(MR.strings.onboarding_permission_type_required)) - + Column { PermissionItem( title = stringResource(MR.strings.onboarding_permission_install_apps), subtitle = stringResource(MR.strings.onboarding_permission_install_apps_description), granted = installGranted, onButtonClick = { - val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { - data = Uri.parse("package:${context.packageName}") - } - } else { - Intent(Settings.ACTION_SECURITY_SETTINGS) - } - context.startActivity(intent) + context.launchRequestPackageInstallsPermission() }, ) - Spacer(modifier = Modifier.height(16.dp)) - - SectionHeader(stringResource(MR.strings.onboarding_permission_type_optional)) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val permissionRequester = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), diff --git a/app/src/main/java/eu/kanade/presentation/util/Permissions.kt b/app/src/main/java/eu/kanade/presentation/util/Permissions.kt new file mode 100644 index 000000000..e3f2df2c8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/Permissions.kt @@ -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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt index 13374b28d..f6ed811a7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Immutable import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope import dev.icerock.moko.resources.StringResource +import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.extension.interactor.GetExtensionsByType import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.presentation.components.SEARCH_DEBOUNCE_MILLIS @@ -34,6 +35,7 @@ import kotlin.time.Duration.Companion.seconds class ExtensionsScreenModel( preferences: SourcePreferences = Injekt.get(), + basePreferences: BasePreferences = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(), private val getExtensions: GetExtensionsByType = Injekt.get(), ) : StateScreenModel(State()) { @@ -124,6 +126,10 @@ class ExtensionsScreenModel( preferences.extensionUpdatesCount().changes() .onEach { mutableState.update { state -> state.copy(updates = it) } } .launchIn(screenModelScope) + + basePreferences.extensionInstaller().changes() + .onEach { mutableState.update { state -> state.copy(installer = it) } } + .launchIn(screenModelScope) } fun search(query: String?) { @@ -199,6 +205,7 @@ class ExtensionsScreenModel( val isRefreshing: Boolean = false, val items: ItemGroups = mutableMapOf(), val updates: Int = 0, + val installer: BasePreferences.ExtensionInstaller? = null, val searchQuery: String? = null, ) { val isEmpty = items.isEmpty() diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index 05c0971fd..96560e59a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -9,6 +9,7 @@ import android.content.res.Configuration import android.net.Uri import android.os.Build import android.os.PowerManager +import android.provider.Settings import androidx.appcompat.view.ContextThemeWrapper import androidx.core.content.getSystemService import androidx.core.net.toUri @@ -167,3 +168,14 @@ fun Context.isInstalledFromFDroid(): Boolean { // F-Droid builds typically disable the updater (!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) +} diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 3e4c68608..640b51434 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -183,8 +183,6 @@ Select a folder where %1$s will store chapter downloads, backups, and more.\n\nA dedicated folder is recommended.\n\nSelected folder: %2$s Select a folder A folder must be selected - Required - Optional Install apps permission To install source extensions. Notification permission @@ -329,6 +327,7 @@ Age rating 18+ Sources from this extension may contain NSFW (18+) content + Permissions are needed to install extensions. Tap here to grant. Installing extension… Installer Legacy