React to changing profile availability

This commit is contained in:
MM20
2024-07-07 14:29:42 +02:00
parent 84f3d9f825
commit 5a28eb584e
29 changed files with 424 additions and 77 deletions
@@ -0,0 +1,54 @@
package de.mm20.launcher2.profiles
import android.content.Context
import android.os.Process
import android.os.UserHandle
import de.mm20.launcher2.ktx.getSerialNumber
data class Profile(
val type: Profile.Type,
val userHandle: UserHandle,
val serial: Long,
) {
override fun equals(other: Any?): Boolean {
if (other is Profile) {
return userHandle == other.userHandle
}
return super.equals(other)
}
override fun hashCode(): Int {
return userHandle.hashCode()
}
enum class Type {
/**
* The default profile.
*/
Personal,
/**
* The work profile.
*/
Work,
/**
* The private space profile (Android 15+)
*/
Private,
}
data class State(
val locked: Boolean = false,
val hidden: Boolean = false,
)
companion object {
fun fromContext(context: Context): Profile {
val userHandle = Process.myUserHandle()
val serial = userHandle.getSerialNumber(context)
return Profile(Profile.Type.Personal, userHandle, serial)
}
}
}
@@ -9,6 +9,7 @@ import de.mm20.launcher2.base.R
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TintedIconLayer
import de.mm20.launcher2.profiles.Profile
interface AppShortcut : SavableSearchable {
@@ -39,6 +40,4 @@ interface AppShortcut : SavableSearchable {
val isUnavailable: Boolean
get() = false
val profile: AppProfile
}
@@ -10,11 +10,7 @@ import de.mm20.launcher2.base.R
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TintedIconLayer
enum class AppProfile {
Personal,
Work,
}
import de.mm20.launcher2.profiles.Profile
interface Application: SavableSearchable {
override val preferDetailsOverLaunch: Boolean
@@ -23,7 +19,6 @@ interface Application: SavableSearchable {
val componentName: ComponentName
val isSystemApp: Boolean
val isSuspended: Boolean
val profile: AppProfile
val user: UserHandle
val versionName: String?
@@ -65,6 +65,7 @@ enum class PermissionGroup {
Notifications,
AppShortcuts,
Accessibility,
HiddenProfiles,
}
internal class PermissionsManagerImpl(
@@ -90,6 +91,9 @@ internal class PermissionsManagerImpl(
private val appShortcutsPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.AppShortcuts)
)
private val hiddenProfilesPermissionState = MutableStateFlow(
checkPermissionOnce(PermissionGroup.HiddenProfiles)
)
override fun requestPermission(context: AppCompatActivity, permissionGroup: PermissionGroup) {
when (permissionGroup) {
@@ -142,6 +146,7 @@ internal class PermissionsManagerImpl(
}
}
PermissionGroup.HiddenProfiles,
PermissionGroup.AppShortcuts -> {
if (isAtLeastApiLevel(29)) {
val roleManager = context.getSystemService<RoleManager>()
@@ -196,6 +201,12 @@ internal class PermissionsManagerImpl(
context.getSystemService<LauncherApps>()?.hasShortcutHostPermission() == true
}
PermissionGroup.HiddenProfiles -> {
if (isAtLeastApiLevel(29)) {
context.getSystemService<RoleManager>()?.isRoleHeld(RoleManager.ROLE_HOME) == true
} else false
}
PermissionGroup.Accessibility -> {
accessibilityPermissionState.value
}
@@ -211,6 +222,7 @@ internal class PermissionsManagerImpl(
PermissionGroup.Notifications -> notificationsPermissionState
PermissionGroup.AppShortcuts -> appShortcutsPermissionState
PermissionGroup.Accessibility -> accessibilityPermissionState
PermissionGroup.HiddenProfiles -> hiddenProfilesPermissionState
}
}
@@ -229,12 +241,14 @@ internal class PermissionsManagerImpl(
PermissionGroup.Notifications -> notificationsPermissionState.value = granted
PermissionGroup.AppShortcuts -> appShortcutsPermissionState.value = granted
PermissionGroup.Accessibility -> accessibilityPermissionState.value = granted
PermissionGroup.HiddenProfiles -> hiddenProfilesPermissionState.value = granted
}
}
override fun onResume() {
externalStoragePermissionState.value = checkPermissionOnce(PermissionGroup.ExternalStorage)
appShortcutsPermissionState.value = checkPermissionOnce(PermissionGroup.AppShortcuts)
hiddenProfilesPermissionState.value = checkPermissionOnce(PermissionGroup.HiddenProfiles)
}
override fun reportNotificationListenerState(running: Boolean) {
+1
View File
@@ -0,0 +1 @@
/build
+50
View File
@@ -0,0 +1,50 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
}
android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
create("nightly") {
initWith(getByName("release"))
matchingFallbacks += "release"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
namespace = "de.mm20.launcher2.profiles"
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.koin.android)
implementation(project(":core:base"))
implementation(project(":core:ktx"))
implementation(project(":core:permissions"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,8 @@
package de.mm20.launcher2.profiles
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val profilesModule = module {
single<ProfileManager> { ProfileManager(androidContext(), get()) }
}
@@ -0,0 +1,168 @@
package de.mm20.launcher2.profiles
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.LauncherApps
import android.os.Process
import android.os.UserHandle
import android.os.UserManager
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
internal data class ProfileWithState(
val profile: Profile,
val state: Profile.State,
)
class ProfileManager(
private val context: Context,
private val permissionsManager: PermissionsManager,
) {
private val userManager = context.getSystemService<UserManager>()!!
private val launcherApps = context.getSystemService<LauncherApps>()!!
private val scope = CoroutineScope(Dispatchers.Default + Job())
private val profileStates: MutableStateFlow<List<ProfileWithState>> =
MutableStateFlow(emptyList())
/**
* List of profiles that are active and unlocked.
*/
val activeProfiles: Flow<List<Profile>> = profileStates.map {
it.mapNotNull {
if (it.state.hidden) null else it.profile
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
init {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
scope.launch {
refreshProfiles()
}
}
}
context.registerReceiver(
receiver, IntentFilter().apply {
addAction(Intent.ACTION_MANAGED_PROFILE_ADDED)
addAction(Intent.ACTION_MANAGED_PROFILE_REMOVED)
addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE)
addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE)
addAction(Intent.ACTION_MANAGED_PROFILE_UNLOCKED)
if (isAtLeastApiLevel(34)) {
addAction(Intent.ACTION_PROFILE_ADDED)
addAction(Intent.ACTION_PROFILE_REMOVED)
}
if (isAtLeastApiLevel(31)) {
addAction(Intent.ACTION_PROFILE_ACCESSIBLE)
addAction(Intent.ACTION_PROFILE_INACCESSIBLE)
}
}
)
scope.launch {
if (isAtLeastApiLevel(35)) {
permissionsManager.hasPermission(PermissionGroup.HiddenProfiles).collectLatest {
refreshProfiles()
}
} else {
refreshProfiles()
}
}
}
private val mutex = Mutex()
private suspend fun refreshProfiles() {
mutex.withLock {
val profiles = mutableListOf<ProfileWithState>()
for (userHandle in launcherApps.profiles) {
profiles.add(
ProfileWithState(
Profile(
type = getProfileType(userHandle),
userHandle = userHandle,
serial = userManager.getSerialNumberForUser(userHandle),
),
getProfileState(userHandle),
)
)
}
profileStates.value = profiles
}
}
fun getProfile(userHandle: UserHandle): Flow<Profile?> {
return profileStates.map {
it.find { it.profile.userHandle == userHandle }?.profile
}
}
fun getProfileState(profile: Profile): Flow<Profile.State?> {
return profileStates.map { profiles ->
profiles.find { it.profile == profile }?.state
}
}
/**
* This only works when the launcher is installed in the primary profile.
*/
private fun getProfileType(userHandle: UserHandle): Profile.Type {
return when {
userManager.isManagedProfile(userHandle) -> Profile.Type.Work
userHandle == Process.myUserHandle() -> Profile.Type.Personal
else -> Profile.Type.Private
}
}
private fun getProfileState(userHandle: UserHandle): Profile.State {
return Profile.State(
locked = !userManager.isUserUnlocked(userHandle),
hidden = !userManager.isUserUnlocked(userHandle),
)
}
@RequiresApi(28)
fun unlockProfile(profile: Profile) {
userManager.requestQuietModeEnabled(false, profile.userHandle)
}
@RequiresApi(28)
fun lockProfile(profile: Profile) {
userManager.requestQuietModeEnabled(true, profile.userHandle)
}
}
internal fun UserManager.isManagedProfile(userHandle: UserHandle): Boolean {
try {
val isManagedProfile = UserManager::class.java.getDeclaredMethod(
"isManagedProfile",
Int::class.javaPrimitiveType
)
val serial = getSerialNumberForUser(userHandle).toInt()
return isManagedProfile.invoke(this, serial) as Boolean
} catch (e: Exception) {
Log.e("MM20", "isManagedProfile could not be invoked", e)
return false
}
}