implement app cloning between profiles

and fixed device policy enforcement issues -- we should set the policies from DummyActivity instead of in the receiver, or it won't work.
This commit is contained in:
Peter Cai 2018-08-22 13:42:43 +08:00
parent 40c4670dfe
commit 0a584c1601
No known key found for this signature in database
GPG key ID: 71F5FB4E4F3FD54F
15 changed files with 317 additions and 28 deletions

View file

@ -29,6 +29,7 @@ dependencies {
implementation 'com.android.support:appcompat-v7:28.0.0-rc01'
implementation 'com.android.support.constraint:constraint-layout:1.1.2'
implementation 'com.android.support:design:28.0.0-rc01'
implementation 'com.android.support:localbroadcastmanager:28.0.0-rc01'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'

View file

@ -6,6 +6,9 @@
<uses-feature android:name="android.software.managed_users" android:required="true"/>
<!--<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"/>-->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:name=".ShelterApplication"
android:allowBackup="false"
@ -17,9 +20,10 @@
<!-- The main activity for UI -->
<activity android:name=".ui.MainActivity"
android:launchMode="singleTask">
>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="net.typeblog.shelter.action.PROVISION_FINISHED" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
@ -29,8 +33,10 @@
<activity android:name=".ui.DummyActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="net.typeblog.shelter.action.FINALIZE_PROVISION" />
<action android:name="net.typeblog.shelter.action.START_SERVICE" />
<action android:name="net.typeblog.shelter.action.TRY_START_SERVICE" />
<action android:name="net.typeblog.shelter.action.INSTALL_PACKAGE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

View file

@ -0,0 +1,6 @@
// IAppInstallCallback.aidl
package net.typeblog.shelter.services;
interface IAppInstallCallback {
void callback(int result);
}

View file

@ -3,11 +3,14 @@ package net.typeblog.shelter.services;
import android.content.pm.ApplicationInfo;
import net.typeblog.shelter.services.IAppInstallCallback;
import net.typeblog.shelter.services.IGetAppsCallback;
import net.typeblog.shelter.services.ILoadIconCallback;
import net.typeblog.shelter.util.ApplicationInfoWrapper;
interface IShelterService {
void stopShelterService(boolean kill);
void getApps(IGetAppsCallback callback);
void loadIcon(in ApplicationInfo info, ILoadIconCallback callback);
void installApp(in ApplicationInfoWrapper app, IAppInstallCallback callback);
}

View file

@ -1,13 +1,11 @@
package net.typeblog.shelter.receivers;
import android.app.admin.DeviceAdminReceiver;
import android.app.admin.DevicePolicyManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import net.typeblog.shelter.ui.DummyActivity;
import net.typeblog.shelter.util.LocalStorageManager;
import net.typeblog.shelter.util.Utility;
public class ShelterDeviceAdminReceiver extends DeviceAdminReceiver {
@Override
@ -25,12 +23,11 @@ public class ShelterDeviceAdminReceiver extends DeviceAdminReceiver {
@Override
public void onProfileProvisioningComplete(Context context, Intent intent) {
super.onProfileProvisioningComplete(context, intent);
DevicePolicyManager manager = context.getSystemService(DevicePolicyManager.class);
ComponentName adminComponent = new ComponentName(context.getApplicationContext(), ShelterDeviceAdminReceiver.class);
// Enable the profile
manager.setProfileEnabled(adminComponent);
Utility.enforceWorkProfilePolicies(context);
// I don't know why setting the policies in this receiver won't work very well
// Anyway, we delegate it to the DummyActivity
Intent i = new Intent(context.getApplicationContext(), DummyActivity.class);
i.setAction(DummyActivity.FINALIZE_PROVISION);
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(i);
}
}

View file

@ -1,20 +1,25 @@
package net.typeblog.shelter.services;
import android.app.Activity;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.app.admin.DevicePolicyManager;
import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.support.annotation.Nullable;
import net.typeblog.shelter.R;
import net.typeblog.shelter.ShelterApplication;
import net.typeblog.shelter.receivers.ShelterDeviceAdminReceiver;
import net.typeblog.shelter.ui.DummyActivity;
import net.typeblog.shelter.util.ApplicationInfoWrapper;
import net.typeblog.shelter.util.Utility;
@ -22,6 +27,8 @@ import java.util.List;
import java.util.stream.Collectors;
public class ShelterService extends Service {
public static final int RESULT_CANNOT_INSTALL_SYSTEM_APP = 100001;
private static final String NOTIFICATION_CHANNEL_ID = "ShelterService";
private DevicePolicyManager mPolicyManager = null;
private boolean mIsWorkProfile = false;
@ -77,6 +84,37 @@ public class ShelterService extends Service {
}
}).start();
}
@Override
public void installApp(ApplicationInfoWrapper app, IAppInstallCallback callback) throws RemoteException {
if ((app.mInfo.flags & ApplicationInfo.FLAG_SYSTEM) == 0) {
// Installing a non-system app requires firing up PackageInstaller
// Delegate this operation to DummyActivity because
// Only it can receive a result
Intent intent = new Intent(DummyActivity.INSTALL_PACKAGE);
intent.setComponent(new ComponentName(ShelterService.this, DummyActivity.class));
intent.putExtra("package", app.mInfo.packageName);
intent.putExtra("apk", app.mInfo.sourceDir);
// Send the callback to the DummyActivity
Bundle callbackExtra = new Bundle();
callbackExtra.putBinder("callback", callback.asBinder());
intent.putExtra("callback", callbackExtra);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
} else {
if (mIsWorkProfile) {
// We can only enable system apps in our own profile
mPolicyManager.enableSystemApp(
new ComponentName(getApplicationContext(), ShelterDeviceAdminReceiver.class),
app.mInfo.packageName);
callback.callback(Activity.RESULT_OK);
} else {
callback.callback(RESULT_CANNOT_INSTALL_SYSTEM_APP);
}
}
}
};
@Override

View file

@ -9,6 +9,7 @@ import android.support.annotation.NonNull;
import android.support.v4.widget.SwipeRefreshLayout;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
@ -37,6 +38,18 @@ public class AppListAdapter extends RecyclerView.Adapter<AppListAdapter.ViewHold
mIcon = view.findViewById(R.id.list_app_icon);
mTitle = view.findViewById(R.id.list_app_title);
mPackage = view.findViewById(R.id.list_app_package);
view.setOnClickListener((v) -> onClick());
}
void onClick() {
if (mIndex == -1) return;
// Show available operations via the Fragment
// pass the full info to it, since we can't be sure
// the index won't change
if (mContextMenuHandler != null) {
mContextMenuHandler.showContextMenu(mList.get(mIndex), mView);
}
}
void setIndex(final int index) {
@ -83,11 +96,17 @@ public class AppListAdapter extends RecyclerView.Adapter<AppListAdapter.ViewHold
}
}
interface ContextMenuHandler {
void showContextMenu(ApplicationInfoWrapper info, View view);
}
private List<ApplicationInfoWrapper> mList = new ArrayList<>();
private IShelterService mService;
private Drawable mDefaultIcon;
private String mLabelDisabled;
private boolean mRefreshing = false;
private Map<String, Bitmap> mIconCache = new HashMap<>();
private ContextMenuHandler mContextMenuHandler = null;
private Handler mHandler = new Handler(Looper.getMainLooper());
private SwipeRefreshLayout mSwipeRefresh;
@ -98,7 +117,13 @@ public class AppListAdapter extends RecyclerView.Adapter<AppListAdapter.ViewHold
mSwipeRefresh = swipeRefresh;
}
void setContextMenuHandler(ContextMenuHandler handler) {
mContextMenuHandler = handler;
}
void refresh() {
if (mRefreshing) return;
mRefreshing = true;
mSwipeRefresh.setRefreshing(true);
mList.clear();
mIconCache.clear();
@ -111,6 +136,7 @@ public class AppListAdapter extends RecyclerView.Adapter<AppListAdapter.ViewHold
mHandler.post(() -> {
mSwipeRefresh.setRefreshing(false);
notifyDataSetChanged();
mRefreshing = false;
});
}
});

View file

@ -1,31 +1,59 @@
package net.typeblog.shelter.ui;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.widget.SwipeRefreshLayout;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.ContextMenu;
import android.view.LayoutInflater;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import net.typeblog.shelter.R;
import net.typeblog.shelter.services.IAppInstallCallback;
import net.typeblog.shelter.services.IShelterService;
import net.typeblog.shelter.services.ShelterService;
import net.typeblog.shelter.util.ApplicationInfoWrapper;
public class AppListFragment extends Fragment {
private static final String BROADCAST_REFRESH = "net.typeblog.shelter.broadcast.REFRESH";
private IShelterService mService = null;
private boolean mIsRemote = false;
private Drawable mDefaultIcon = null;
private ApplicationInfoWrapper mSelectedApp = null;
// Views
private RecyclerView mList = null;
private AppListAdapter mAdapter = null;
private SwipeRefreshLayout mSwipeRefresh = null;
// Receiver for Refresh events
// used for app changes
private BroadcastReceiver mRefreshReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (mAdapter != null) {
mAdapter.refresh();
}
}
};
static AppListFragment newInstance(IShelterService service, boolean isRemote) {
AppListFragment fragment = new AppListFragment();
Bundle args = new Bundle();
@ -44,6 +72,24 @@ public class AppListFragment extends Fragment {
mIsRemote = getArguments().getBoolean("is_remote");
}
@Override
public void onResume() {
super.onResume();
LocalBroadcastManager.getInstance(getContext())
.registerReceiver(mRefreshReceiver, new IntentFilter(BROADCAST_REFRESH));
if (mAdapter != null) {
mAdapter.refresh();
}
}
@Override
public void onPause() {
super.onPause();
mSelectedApp = null;
LocalBroadcastManager.getInstance(getContext())
.unregisterReceiver(mRefreshReceiver);
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
@ -53,13 +99,68 @@ public class AppListFragment extends Fragment {
mList = view.findViewById(R.id.fragment_list_recycler_view);
mSwipeRefresh = view.findViewById(R.id.fragment_swipe_refresh);
mAdapter = new AppListAdapter(mService, mDefaultIcon, mSwipeRefresh);
mAdapter.setContextMenuHandler((info, v) -> {
mSelectedApp = info;
mList.showContextMenuForChild(v);
});
mList.setAdapter(mAdapter);
mList.setLayoutManager(new LinearLayoutManager(getActivity()));
mList.setHasFixedSize(true);
mAdapter.refresh();
mSwipeRefresh.setOnRefreshListener(mAdapter::refresh);
registerForContextMenu(mList);
return view;
}
@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
super.onCreateContextMenu(menu, v, menuInfo);
MenuInflater inflater = getActivity().getMenuInflater();
if (mIsRemote) {
inflater.inflate(R.menu.menu_work, menu);
} else {
inflater.inflate(R.menu.menu_main, menu);
}
}
@Override
public boolean onContextItemSelected(MenuItem item) {
if (mSelectedApp == null) return false;
switch (item.getItemId()) {
case R.id.main_clone_to_work:
case R.id.work_clone_to_main:
// Make a local copy
final ApplicationInfoWrapper app = mSelectedApp;
mSelectedApp = null;
try {
((MainActivity) getActivity()).getOtherService(mIsRemote)
.installApp(app, new IAppInstallCallback.Stub() {
@Override
public void callback(int result) {
installAppCallback(result, app);
}
});
} catch (RemoteException e) {
// TODO: Maybe tell the user?
}
return true;
default:
return super.onContextItemSelected(item);
}
}
void installAppCallback(int result, ApplicationInfoWrapper app) {
if (result == Activity.RESULT_OK) {
String message = getString(R.string.clone_success);
message = String.format(message, app.mLabel);
Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show();
LocalBroadcastManager.getInstance(getContext())
.sendBroadcast(new Intent(BROADCAST_REFRESH));
} else if (result == ShelterService.RESULT_CANNOT_INSTALL_SYSTEM_APP) {
Toast.makeText(getContext(),
getString(R.string.clone_fail_system_app), Toast.LENGTH_SHORT).show();
}
}
}

View file

@ -5,30 +5,47 @@ import android.app.admin.DevicePolicyManager;
import android.content.ComponentName;
import android.content.Intent;
import android.content.ServiceConnection;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.os.StrictMode;
import android.support.annotation.Nullable;
import android.widget.Toast;
import net.typeblog.shelter.R;
import net.typeblog.shelter.ShelterApplication;
import net.typeblog.shelter.services.IAppInstallCallback;
import net.typeblog.shelter.util.Utility;
import java.io.File;
// DummyActivity does nothing about presenting any UI
// It is a wrapper over various different operations
// that might be required to perform across user profiles
// which is only possible through Intents that are in
// the crossProfileIntentFilter
public class DummyActivity extends Activity {
public static final String FINALIZE_PROVISION = "net.typeblog.shelter.action.FINALIZE_PROVISION";
public static final String START_SERVICE = "net.typeblog.shelter.action.START_SERVICE";
public static final String TRY_START_SERVICE = "net.typeblog.shelter.action.TRY_START_SERVICE";
public static final String INSTALL_PACKAGE = "net.typeblog.shelter.action.INSTALL_PACKAGE";
private static final int REQUEST_INSTALL_PACKAGE = 1;
private boolean mIsProfileOwner = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getSystemService(DevicePolicyManager.class).isProfileOwnerApp(getPackageName())) {
mIsProfileOwner = getSystemService(DevicePolicyManager.class).isProfileOwnerApp(getPackageName());
if (mIsProfileOwner) {
// If we are the profile owner, we enforce all our policies
// so that we can make sure those are updated with our app
Utility.enforceWorkProfilePolicies(this);
Utility.enforceUserRestrictions(this);
}
Intent intent = getIntent();
@ -39,9 +56,30 @@ public class DummyActivity extends Activity {
// This is used for testing if work mode is disabled from MainActivity
setResult(RESULT_OK);
finish();
} else if (INSTALL_PACKAGE.equals(intent.getAction())) {
actionInstallPackage();
} else if (FINALIZE_PROVISION.equals(intent.getAction())) {
actionFinalizeProvision();
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_INSTALL_PACKAGE) {
appInstallFinished(resultCode);
}
}
private void actionFinalizeProvision() {
// This is the action used by DeviceAdminReceiver to finalize the setup
// The work has been finished in onCreate(), now we just have to
// inform the user to restart the main activity
Toast.makeText(this, getString(R.string.provision_finished), Toast.LENGTH_LONG).show();
finish();
}
private void actionStartService() {
((ShelterApplication) getApplication()).bindShelterService(new ServiceConnection() {
@Override
@ -60,4 +98,44 @@ public class DummyActivity extends Activity {
}
});
}
private void actionInstallPackage() {
Uri uri = Uri.fromParts("package", getIntent().getStringExtra("package"), null);
StrictMode.VmPolicy policy = StrictMode.getVmPolicy();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// I really have no idea about why the "package:" uri do not work
// after Android O, anyway we fall back to using the apk path...
// Since I have plan to support pre-O in later versions, I keep this
// branch in case that we reduce minSDK in the future.
uri = Uri.fromFile(new File(getIntent().getStringExtra("apk")));
// A permissive VmPolicy must be set to work around
// the limitation on cross-application Uri
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().build());
}
Intent intent = new Intent(Intent.ACTION_INSTALL_PACKAGE, uri);
intent.putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, getPackageName());
intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true);
intent.putExtra(Intent.EXTRA_RETURN_RESULT, true);
startActivityForResult(intent, REQUEST_INSTALL_PACKAGE);
// Restore the VmPolicy anyway
StrictMode.setVmPolicy(policy);
}
private void appInstallFinished(int resultCode) {
// Send the result code back to the caller
Bundle callbackExtra = getIntent().getBundleExtra("callback");
IAppInstallCallback callback = IAppInstallCallback.Stub
.asInterface(callbackExtra.getBinder("callback"));
try {
callback.callback(resultCode);
} catch (RemoteException e) {
// do nothing
}
finish();
}
}

View file

@ -1,11 +1,8 @@
package net.typeblog.shelter.ui;
import android.app.admin.DevicePolicyManager;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.ServiceConnection;
import android.os.IBinder;
import android.support.annotation.Nullable;
@ -172,6 +169,13 @@ public class MainActivity extends AppCompatActivity {
mTabs.setupWithViewPager(mPager);
}
// Get the service on the other side
// remote (work) -> main
// main -> remote (work)
IShelterService getOtherService(boolean isRemote) {
return isRemote ? mServiceMain : mServiceWork;
}
@Override
protected void onDestroy() {
super.onDestroy();
@ -194,20 +198,17 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_PROVISION_PROFILE) {
if (resultCode == RESULT_OK) {
// The sync part of the setup process is completed
// We register a receiver to wait for the async part
registerReceiver(new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
unregisterReceiver(this); // We only want to receive this once
mStorage.setBoolean(LocalStorageManager.PREF_HAS_SETUP, true);
bindServices();
}
}, new IntentFilter(DevicePolicyManager.ACTION_MANAGED_PROFILE_PROVISIONED));
// Let's just assume it succeeded. If it did not, the program will break
// on the next start anyway.
mStorage.setBoolean(LocalStorageManager.PREF_HAS_SETUP, true);
// However, we still have to wait for DummyActivity in work profile to finish
Toast.makeText(this,
getString(R.string.provision_still_pending), Toast.LENGTH_LONG).show();
finish();
} else {
Toast.makeText(this,
getString(R.string.work_profile_provision_failed), Toast.LENGTH_LONG).show();
@ -240,6 +241,8 @@ public class MainActivity extends AppCompatActivity {
Toast.makeText(this, getString(R.string.device_admin_toast), Toast.LENGTH_LONG).show();
finish();
}
} else {
super.onActivityResult(requestCode, resultCode, data);
}
}

View file

@ -11,6 +11,7 @@ import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.UserManager;
import net.typeblog.shelter.receivers.ShelterDeviceAdminReceiver;
import net.typeblog.shelter.services.IShelterService;
@ -55,6 +56,15 @@ public class Utility {
adminComponent,
new IntentFilter(DummyActivity.TRY_START_SERVICE),
DevicePolicyManager.FLAG_MANAGED_CAN_ACCESS_PARENT);
manager.setProfileEnabled(adminComponent);
}
public static void enforceUserRestrictions(Context context) {
DevicePolicyManager manager = context.getSystemService(DevicePolicyManager.class);
ComponentName adminComponent = new ComponentName(context.getApplicationContext(), ShelterDeviceAdminReceiver.class);
manager.clearUserRestriction(adminComponent, UserManager.DISALLOW_INSTALL_APPS);
manager.clearUserRestriction(adminComponent, UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES);
}
// From <https://stackoverflow.com/questions/3035692/how-to-convert-a-drawable-to-a-bitmap>

View file

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:clickable="true"
android:foreground="?android:attr/selectableItemBackground"
android:layout_width="match_parent"
android:layout_height="64dp">

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/main_clone_to_work"
android:title="@string/clone_to_work_profile" />
</menu>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/work_clone_to_main"
android:title="@string/clone_to_main_profile" />
</menu>

View file

@ -2,8 +2,10 @@
<string name="app_name">Shelter</string>
<string name="device_admin_label">Shelter</string>
<string name="device_admin_desc">App Isolation Service</string>
<string name="device_admin_explanation">Shelter needs to become Device Admin in order to perform its isolation tasks.</string>\
<string name="device_admin_explanation">Shelter needs to become Device Admin in order to perform its isolation tasks.</string>
<string name="device_admin_toast">You have to grant Device Admin permission for Shelter to work. Please try again.</string>
<string name="provision_still_pending">Please wait while we prepare Shelter profile for you &#8230;</string>
<string name="provision_finished">Shelter setup complete. Please restart Shelter.</string>
<string name="msg_device_unsupported">Permission is denied or Unsupported device</string>
<string name="work_profile_not_found">Work profile not found. Please restart the app to re-provision the profile.</string>
<string name="work_profile_provision_failed">Cannot provision work profile. You may try again by restarting Shelter.</string>
@ -13,4 +15,8 @@
<string name="fragment_profile_main">Main</string>
<string name="fragment_profile_work">Shelter</string>
<string name="list_item_disabled">[Frozen] %s</string>
<string name="clone_to_work_profile">Clone to Shelter (Work Profile)</string>
<string name="clone_to_main_profile">Clone to Main Profile</string>
<string name="clone_success">Application "%s" cloned successfully</string>
<string name="clone_fail_system_app">Cannot clone system apps to a profile that Shelter has no control of.</string>
</resources>