React to changing profile availability
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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"))
|
||||
}
|
||||
Vendored
+21
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user