From 318c26a4396eecb415de24910aa18963237a76fc Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Tue, 13 Aug 2024 15:19:24 +0200 Subject: [PATCH] Mask private profile apps while private space is locked --- .../ui/launcher/search/apps/AppItem.kt | 158 ++++++++++-------- .../de/mm20/launcher2/search/Application.kt | 7 +- core/i18n/src/main/res/values/strings.xml | 1 + data/applications/build.gradle.kts | 1 + .../de/mm20/launcher2/applications/FakeApp.kt | 1 - .../applications/AppSerialization.kt | 34 +++- .../launcher2/applications/LauncherApp.kt | 2 +- .../applications/LockedPrivateProfileApp.kt | 103 ++++++++++++ .../de/mm20/launcher2/icons/IconService.kt | 9 + 9 files changed, 242 insertions(+), 74 deletions(-) create mode 100644 data/applications/src/main/java/de/mm20/launcher2/applications/LockedPrivateProfileApp.kt diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt index 2a9390be..0cbb40db 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/apps/AppItem.kt @@ -111,34 +111,43 @@ fun AppItem( style = MaterialTheme.typography.titleMedium ) - val tags by viewModel.tags.collectAsState(emptyList()) - if (tags.isNotEmpty()) { - Text( - modifier = Modifier.padding(top = 1.dp, bottom = 4.dp), - text = tags.joinToString(separator = " #", prefix = "#"), - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.labelSmall - ) - } + if (!app.isPrivate) { + + val tags by viewModel.tags.collectAsState(emptyList()) + if (tags.isNotEmpty()) { + Text( + modifier = Modifier.padding(top = 1.dp, bottom = 4.dp), + text = tags.joinToString(separator = " #", prefix = "#"), + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.labelSmall + ) + } - app.versionName?.let { + app.versionName?.let { + Text( + text = stringResource(R.string.app_info_version, it), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } Text( - text = stringResource(R.string.app_info_version, it), + text = app.componentName.packageName, style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 4.dp), + modifier = Modifier.padding(top = 1.dp), maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + stringResource(R.string.profile_private_profile_state_locked), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.secondary, ) } - Text( - text = app.componentName.packageName, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 1.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } val badge by viewModel.badge.collectAsStateWithLifecycle(null) @@ -373,13 +382,15 @@ fun AppItem( toolbarActions.add(favAction) } - toolbarActions.add( - DefaultToolbarAction( - label = stringResource(R.string.menu_app_info), - icon = Icons.Rounded.Info - ) { - app.openAppDetails(context) - }) + if (!app.isPrivate) { + toolbarActions.add( + DefaultToolbarAction( + label = stringResource(R.string.menu_app_info), + icon = Icons.Rounded.Info + ) { + app.openAppDetails(context) + }) + } toolbarActions.add( DefaultToolbarAction( @@ -392,49 +403,56 @@ fun AppItem( ) val sheetManager = LocalBottomSheetManager.current - toolbarActions.add(DefaultToolbarAction( - label = stringResource(R.string.menu_customize), - icon = Icons.Rounded.Tune, - action = { sheetManager.showCustomizeSearchableModal(app) } - )) - - val storeDetails = remember(app) { app.getStoreDetails(context) } - val shareAction = if (storeDetails == null) { - DefaultToolbarAction( - label = stringResource(R.string.menu_share), - icon = Icons.Rounded.Share - ) { - scope.launch { - app.shareApkFile(context) - } - } - } else { - SubmenuToolbarAction( - label = stringResource(R.string.menu_share), - icon = Icons.Rounded.Share, - children = listOf( - DefaultToolbarAction( - label = stringResource(R.string.menu_share_store_link, storeDetails.label), - icon = Icons.Rounded.Link, - action = { - val shareIntent = Intent(Intent.ACTION_SEND) - shareIntent.putExtra(Intent.EXTRA_TEXT, storeDetails.url) - shareIntent.type = "text/plain" - context.startActivity(Intent.createChooser(shareIntent, null)) - } - ), - DefaultToolbarAction( - label = stringResource(R.string.menu_share_apk_file), - icon = Icons.Rounded.Android - ) { - scope.launch { - app.shareApkFile(context) - } - } - ) - ) + if (!app.isPrivate) { + toolbarActions.add(DefaultToolbarAction( + label = stringResource(R.string.menu_customize), + icon = Icons.Rounded.Tune, + action = { sheetManager.showCustomizeSearchableModal(app) } + )) + } + + if (!app.isPrivate) { + val storeDetails = remember(app) { app.getStoreDetails(context) } + val shareAction = if (storeDetails == null) { + DefaultToolbarAction( + label = stringResource(R.string.menu_share), + icon = Icons.Rounded.Share + ) { + scope.launch { + app.shareApkFile(context) + } + } + } else { + SubmenuToolbarAction( + label = stringResource(R.string.menu_share), + icon = Icons.Rounded.Share, + children = listOf( + DefaultToolbarAction( + label = stringResource( + R.string.menu_share_store_link, + storeDetails.label + ), + icon = Icons.Rounded.Link, + action = { + val shareIntent = Intent(Intent.ACTION_SEND) + shareIntent.putExtra(Intent.EXTRA_TEXT, storeDetails.url) + shareIntent.type = "text/plain" + context.startActivity(Intent.createChooser(shareIntent, null)) + } + ), + DefaultToolbarAction( + label = stringResource(R.string.menu_share_apk_file), + icon = Icons.Rounded.Android + ) { + scope.launch { + app.shareApkFile(context) + } + } + ) + ) + } + toolbarActions.add(shareAction) } - toolbarActions.add(shareAction) if (app.canUninstall) { toolbarActions.add( diff --git a/core/base/src/main/java/de/mm20/launcher2/search/Application.kt b/core/base/src/main/java/de/mm20/launcher2/search/Application.kt index 4a36d9e0..0efda7c4 100644 --- a/core/base/src/main/java/de/mm20/launcher2/search/Application.kt +++ b/core/base/src/main/java/de/mm20/launcher2/search/Application.kt @@ -17,8 +17,13 @@ interface Application: SavableSearchable { get() = false val componentName: ComponentName - val isSystemApp: Boolean val isSuspended: Boolean + + /** + * If true, the app's identity should not be revealed to the user. + */ + val isPrivate: Boolean + get() = false val user: UserHandle val versionName: String? diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index a11efd46..59c2f4ae 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -806,6 +806,7 @@ Apply theme The selected file could not be read. Please make sure that you selected a valid theme file (*.kvtheme), and that the file is not corrupt. Unavailable + Locked This shortcut is unavailable because %1$s isn\'t the default launcher Enable plugin diff --git a/data/applications/build.gradle.kts b/data/applications/build.gradle.kts index ca47f64b..4914e8ab 100644 --- a/data/applications/build.gradle.kts +++ b/data/applications/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(libs.bundles.kotlin) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) + implementation(libs.androidx.compose.materialicons) implementation(libs.bundles.androidx.lifecycle) diff --git a/data/applications/src/debug/java/de/mm20/launcher2/applications/FakeApp.kt b/data/applications/src/debug/java/de/mm20/launcher2/applications/FakeApp.kt index d5392ef5..8e1a0653 100644 --- a/data/applications/src/debug/java/de/mm20/launcher2/applications/FakeApp.kt +++ b/data/applications/src/debug/java/de/mm20/launcher2/applications/FakeApp.kt @@ -12,7 +12,6 @@ import de.mm20.launcher2.search.SearchableSerializer class FakeApp: Application { override val componentName: ComponentName = ComponentName(randomString(), randomString()) - override val isSystemApp: Boolean = false override val isSuspended: Boolean = false override val user: UserHandle = Process.myUserHandle() override val versionName: String = "1.0" diff --git a/data/applications/src/main/java/de/mm20/launcher2/applications/AppSerialization.kt b/data/applications/src/main/java/de/mm20/launcher2/applications/AppSerialization.kt index 3907ff26..c4a8b938 100644 --- a/data/applications/src/main/java/de/mm20/launcher2/applications/AppSerialization.kt +++ b/data/applications/src/main/java/de/mm20/launcher2/applications/AppSerialization.kt @@ -7,11 +7,26 @@ import android.content.pm.LauncherApps import android.os.UserManager import android.util.Log import androidx.core.content.getSystemService +import de.mm20.launcher2.ktx.isAtLeastApiLevel import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SearchableDeserializer import de.mm20.launcher2.search.SearchableSerializer import org.json.JSONObject +internal class LockedPrivateProfileAppSerializer : SearchableSerializer { + override fun serialize(searchable: SavableSearchable): String { + searchable as LockedPrivateProfileApp + val json = JSONObject() + json.put("package", searchable.componentName.packageName) + json.put("activity", searchable.componentName.className) + json.put("user", searchable.userSerialNumber) + return json.toString() + } + + override val typePrefix: String + get() = "app" +} + class LauncherAppSerializer : SearchableSerializer { override fun serialize(searchable: SavableSearchable): String { searchable as LauncherApp @@ -33,9 +48,26 @@ class LauncherAppDeserializer(val context: Context) : SearchableDeserializer { val userManager = context.getSystemService()!! val userSerial = json.optLong("user") val user = userManager.getUserForSerialNumber(userSerial) ?: return null + val pkg = json.getString("package") + val activity = json.getString("activity") + + val componentName = ComponentName(pkg, activity) + + if (isAtLeastApiLevel(35)) { + val launcherUser = launcherApps.getLauncherUserInfo(user) ?: return null + if (launcherUser.userType == UserManager.USER_TYPE_PROFILE_PRIVATE && userManager.isQuietModeEnabled(user)) { + return LockedPrivateProfileApp( + label = context.getString(R.string.app_label_locked_profile), + componentName = componentName, + user = user, + userSerialNumber = userSerial + ) + } + } + val intent = Intent().also { - it.component = ComponentName(pkg, json.getString("activity")) + it.component = componentName } try { val launcherActivityInfo = launcherApps.resolveActivity(intent, user) ?: return null diff --git a/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt b/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt index 28bd72c7..d52ddb25 100644 --- a/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt +++ b/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt @@ -60,7 +60,7 @@ internal data class LauncherApp( private val isMainProfile = launcherActivityInfo.user == Process.myUserHandle() - override val isSystemApp: Boolean = launcherActivityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0 + private val isSystemApp: Boolean = launcherActivityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0 override val canUninstall: Boolean get() = !isSystemApp && isMainProfile diff --git a/data/applications/src/main/java/de/mm20/launcher2/applications/LockedPrivateProfileApp.kt b/data/applications/src/main/java/de/mm20/launcher2/applications/LockedPrivateProfileApp.kt new file mode 100644 index 00000000..028ce215 --- /dev/null +++ b/data/applications/src/main/java/de/mm20/launcher2/applications/LockedPrivateProfileApp.kt @@ -0,0 +1,103 @@ +package de.mm20.launcher2.applications + +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.pm.LauncherApps +import android.os.Bundle +import android.os.UserHandle +import android.os.UserManager +import android.util.Log +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Lock +import androidx.core.content.getSystemService +import de.mm20.launcher2.icons.ColorLayer +import de.mm20.launcher2.icons.LauncherIcon +import de.mm20.launcher2.icons.StaticLauncherIcon +import de.mm20.launcher2.icons.VectorLayer +import de.mm20.launcher2.ktx.isAtLeastApiLevel +import de.mm20.launcher2.search.Application +import de.mm20.launcher2.search.SavableSearchable +import de.mm20.launcher2.search.SearchableSerializer + +internal data class LockedPrivateProfileApp( + override val label: String, + override val componentName: ComponentName, + override val user: UserHandle, + internal val userSerialNumber: Long, +): Application { + override val isSuspended: Boolean = false + override val versionName: String? = null + override val canUninstall: Boolean = false + + override val isPrivate: Boolean = true + + override fun uninstall(context: Context) { + // Do nothing + } + + override fun openAppDetails(context: Context) { + // Do nothing + } + + override val domain: String = LauncherApp.Domain + override val canShareApk: Boolean = false + + override val key: String = "${domain}://${componentName.packageName}:${componentName.className}:${userSerialNumber}" + + override fun overrideLabel(label: String): SavableSearchable { + // We don't expose custom labels for locked apps + return this + } + + override fun launch(context: Context, options: Bundle?): Boolean { + if (!isAtLeastApiLevel(35)) return false + + val userManager = context.getSystemService() ?: return false + + if (userManager.isQuietModeEnabled(user)) { + userManager.requestQuietModeEnabled(false, user) + return true + } + + val launcherApps = context.getSystemService() ?: return false + + if (isAtLeastApiLevel(31)) { + options?.putInt("android.activity.splashScreenStyle", 1) + } + + try { + launcherApps.startMainActivity( + componentName, + user, + null, + options + ) + } catch (e: SecurityException) { + Log.e("MM20", "Could not launch app", e) + return false + } catch (e: ActivityNotFoundException) { + Log.e("MM20", "Could not launch app", e) + return false + } + return true + } + + override fun getPlaceholderIcon(context: Context): StaticLauncherIcon { + return StaticLauncherIcon( + foregroundLayer = VectorLayer( + vector = Icons.Rounded.Lock, + ), + backgroundLayer = ColorLayer(0) + ) + } + + override suspend fun loadIcon(context: Context, size: Int, themed: Boolean): LauncherIcon? { + return null + } + + + override fun getSerializer(): SearchableSerializer { + return LockedPrivateProfileAppSerializer() + } +} \ No newline at end of file diff --git a/services/icons/src/main/java/de/mm20/launcher2/icons/IconService.kt b/services/icons/src/main/java/de/mm20/launcher2/icons/IconService.kt index e470cc1f..b8d24da4 100644 --- a/services/icons/src/main/java/de/mm20/launcher2/icons/IconService.kt +++ b/services/icons/src/main/java/de/mm20/launcher2/icons/IconService.kt @@ -46,6 +46,9 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch class IconService( @@ -140,6 +143,12 @@ class IconService( fun getIcon(searchable: SavableSearchable, size: Int): Flow { + if (searchable is Application && searchable.isPrivate) { + return transformations.map { + searchable.getPlaceholderIcon(context).transform(it) + } + } + val customIcon = customAttributesRepository.getCustomIcon(searchable) return combine(iconProviders, transformations, customIcon) { providers, transformations, ci -> var icon = cache.get(searchable.key + ci.hashCode() + providers.hashCode() + transformations.hashCode())