Permission not granted - on failing to create cross-profile app pairs

tkuenneth

Thomas Künneth

Posted on February 2, 2024

Permission not granted - on failing to create cross-profile app pairs

Be nice is a simple and friendly app. Its idea is to provide a better user experience for apps that are not really looking good on tablets and foldables because they force the device into portrait mode. In a way, I made it for myself, as I was experiencing said issues with many apps on my Surface Duo. Yes, I am still using my Surface Duo in 2024. But I figured users of tablets might like Be nice, too, because that portrait-lock-in is a pain on large screens as well. It turns out, it hit a nerve of quite a few Pixel Fold owners. I learned that currently the Pixel Fold does not provide app pairs. What Be nice does is essentially creating app pairs, so generalising the functionality was not a big deal. That brought my app center-stage on the YouTube channel of Shane Craig. What followed was quite some praise and enthusiasm of users - and a request:

Could you please enable app pairs across profiles?

Many of us are using our devices for work and personal stuff. Android supports this through so-called Work Profiles. How this machinery works under the hood, is worth a whole series of articles, so I will remain a little vague and general regarding technical terms. To learn more, kindly refer to the official documentation.

Launching apps in split-screen mode

Before we turn to my failed attempts of running an app in another profile (and how we learn about profiles anyway), let's look at how Be nice launches app pairs. The app is open source, so the complete code is available on GitHub. I rely solely on public APIs and do not hook into the system. This makes launching an app pair charmingly unorthodox.

  • A shortcut launches BeNiceActivity
  • The intent contains both package name and class name of the two apps (well, activities) to be launched
  • First, the first activity is started
  • Then we wait a little while
  • Then we launch the second activity


class BeNiceActivity : ComponentActivity() {

  override fun onResume() {
    super.onResume()
    val launchAdjacent = shouldLaunchAdjacent(
      prefs = PreferenceManager.getDefaultSharedPreferences(this),
      windowSizeClass = computeWindowSizeClass()
    )
    if (launchAdjacent) {
      Intent(this, AppChooserActivity::class.java).run {
        addFlags(FLAG_ACTIVITY_NEW_TASK)
        startActivity(this)
      }
    }
    Handler(Looper.getMainLooper()).postDelayed({
      launchApp(
        intent = intent,
        launchAdjacent = launchAdjacent
      )
      finish()
    }, if (launchAdjacent) 500L else 0L)
  }

  private fun launchApp(intent: Intent, launchAdjacent: Boolean) {
    lifecycleScope.launch {
      if (ACTION_LAUNCH_APP == intent.action) {
        intent.getStringExtra(PACKAGE_NAME)?.let { packageName ->
          intent.getStringExtra(CLASS_NAME)?.let { className ->
            launchApp(
              packageName = packageName,
              className = className,
              launchAdjacent = launchAdjacent
            )
          }
        }
      }
    }
  }
}

fun Context.launchApp(
  packageName: String,
  className: String,
  launchAdjacent: Boolean
) {
  Intent().run {
    component = ComponentName(
      packageName,
      className
    )
    addFlags(FLAG_ACTIVITY_NEW_TASK)
    if (launchAdjacent) {
      addFlags(
        FLAG_ACTIVITY_LAUNCH_ADJACENT or
            FLAG_ACTIVITY_TASK_ON_HOME
      )
    }
    startActivity(this)
  }
}

fun Context.createBeNiceLaunchIntent(appInfo: AppInfo) =
  Intent(this, BeNiceActivity::class.java).also { intent ->
    intent.action = ACTION_LAUNCH_APP
    intent.addFlags(
      FLAG_ACTIVITY_NEW_TASK or
          FLAG_ACTIVITY_CLEAR_TASK
    )
    intent.putExtra(PACKAGE_NAME, appInfo.packageName)
    intent.putExtra(CLASS_NAME, appInfo.className)
  }


Enter fullscreen mode Exit fullscreen mode

That's pretty easy to digest, right? In a nutshell, Be nice calls startActivity() and configures the intent flags based on whether the second app should be launched adjacent (in split-screen mode) or not. If, for example, a foldable is closed, the user may want to use just one app. That's why the code computes window size classes.

About profiles

If an app is installed in another profile, we need to know that it's there, and how we can launch it. But how do we know about profiles?

CrossProfileApps is (quoted from the docs) a class

for handling cross profile operations. Apps can use this class to interact with its instance in any profile that is in getTargetUserProfiles(). For example, app can use this class to start its main activity in managed profile.

Well, in a way this sounds cool, but not entirely what we need. We don't want to start our main activity but another one, to be more precisely, an activity that belongs to another app. Browsing through the available methods seems to reveal just the one we need:



public void startActivity (Intent intent, 
        UserHandle targetUser, 
        Activity callingActivity)


Enter fullscreen mode Exit fullscreen mode

You can guess that I was pretty enthusiastic until I read that Be nice must hold either the INTERACT_ACROSS_PROFILES or INTERACT_ACROSS_USERS permission. Which it (to my knowledge) cannot do as a third party app that is distributed through Google Play, due to the protection levels of these permissions.

So, what to do? startMainActivity() doesn't require those restrictive permissions. It can receive a ComponentName which should allow us to specify the activity of other apps, if we know both package name and class name. Unfortunately, the documentation tells us that the method starts

the specified main activity of the caller package in the specified profile.

Well, the caller package would be Be nice, but that's not what we want. The source code reveals why it won't work:

Screenshot of the source code of startMainActivity()

I tried it anyway, and expectedly got this:



java.lang.SecurityException: de.thomaskuenneth.benice cannot access unrelated user 10


Enter fullscreen mode Exit fullscreen mode

User 10, by the way, refers to the Work Profile. How I learned about that is pretty interesting, so let me share this. CrossProfileApps offers a method called getTargetUserProfiles(), which returns

a list of user profiles that that the caller can use when calling other APIs in this class

Showing the result of a call to getTargetUserProfiles()

As you can see, the list is empty. This implies that Be nice can't interact with other profiles. I should have known.

There are two query functions, canInteractAcrossProfiles() and canRequestInteractAcrossProfiles(). The latter one indicates that we might be able to request an interaction (using createRequestInteractAcrossProfilesIntent()). Needless to say that both return false. But that doesn't explain how I learned about user 10.

There's another manager in town, called UserManager. It offers a function called getUserProfiles(), which returns

a list of UserHandles for profiles associated with the context user, including the user itself.



val userManager = getSystemService(UserManager::class.java)
userManager.userProfiles.forEach {
  println("===> $it")
}


Enter fullscreen mode Exit fullscreen mode

LogCat showing two UserHandles

To understand the output, let's look at some code snippets from UserHandle:

Snippet of the UserHandle source code

So, user 0 represents the device owner.

Snippet of the UserHandle source code

User 10 represents a secondary user. I got that one on my testing device after installing Test DPC, a unique tool that helps you understanding Work Profiles.

Conclusion

Please recall that my goal was enhance the app pair feature of Be nice by allowing the user to launch apps in other profiles. I did not succeed. From my understanding, ordinary apps cannot acquire the permissions needed to achieve this. If I missed something, please do not hesitate to share your thoughts in the comments.

💖 💪 🙅 🚩
tkuenneth
Thomas Künneth

Posted on February 2, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related