implement cross-profile DocumentsProvider

now we can freely access files across profile through Documents UI
This commit is contained in:
Peter Cai 2018-09-19 12:23:38 +08:00
parent 08fa244e76
commit 58fc86f93e
No known key found for this signature in database
GPG key ID: 71F5FB4E4F3FD54F
8 changed files with 423 additions and 0 deletions

View file

@ -11,6 +11,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:maxSdkVersion="25"
android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:name=".ShelterApplication"
@ -49,6 +50,10 @@
<action android:name="net.typeblog.shelter.action.PUBLIC_UNFREEZE_AND_LAUNCH" />
<action android:name="net.typeblog.shelter.action.PUBLIC_FREEZE_ALL" />
<action android:name="net.typeblog.shelter.action.FREEZE_ALL_IN_LIST" />
<!-- We need two of these to avoid being prompted with an action chooser dialog -->
<!-- When the intent is actually already forwarded to work profile -->
<action android:name="net.typeblog.shelter.action.START_FILE_SHUTTLE" />
<action android:name="net.typeblog.shelter.action.START_FILE_SHUTTLE_2" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
@ -78,12 +83,29 @@
android:resource="@xml/file_paths" />
</provider>
<!-- A DocumentsProvider that lists files in another profile -->
<provider
android:name="net.typeblog.shelter.util.CrossProfileDocumentsProvider"
android:authorities="net.typeblog.shelter.documents"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:exported="true">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<!-- Core service running on both the main profile and the work profile -->
<!-- Actions like cloning / freezing apps will be performed by this service -->
<service android:name=".services.ShelterService"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN"/>
<!-- Service to forward file information between profiles -->
<service android:name=".services.FileShuttleService"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN" />
<!-- A hack service to ensure every ShelterService is killed when App is removed -->
<!-- from recent tasks -->
<service android:name=".services.KillerService" />

View file

@ -0,0 +1,11 @@
// IFileShuttleService.aidl
package net.typeblog.shelter.services;
import android.os.ParcelFileDescriptor;
interface IFileShuttleService {
void ping();
List loadFiles(String path);
Map loadFileMeta(String path);
ParcelFileDescriptor openFile(String path, String mode);
}

View file

@ -0,0 +1,8 @@
// IFileShuttleServiceCallback.aidl
package net.typeblog.shelter.services;
import net.typeblog.shelter.services.IFileShuttleService;
interface IFileShuttleServiceCallback {
void callback(in IFileShuttleService service);
}

View file

@ -5,11 +5,13 @@ import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import net.typeblog.shelter.services.FileShuttleService;
import net.typeblog.shelter.services.ShelterService;
import net.typeblog.shelter.util.LocalStorageManager;
public class ShelterApplication extends Application {
private ServiceConnection mShelterServiceConnection = null;
private ServiceConnection mFileShuttleServiceConnection = null;
@Override
public void onCreate() {
@ -25,6 +27,13 @@ public class ShelterApplication extends Application {
mShelterServiceConnection = conn;
}
public void bindFileShuttleService(ServiceConnection conn) {
unbindFileShuttleService();;
Intent intent = new Intent(getApplicationContext(), FileShuttleService.class);
bindService(intent, conn, Context.BIND_AUTO_CREATE);
mFileShuttleServiceConnection = conn;
}
public void unbindShelterService() {
if (mShelterServiceConnection != null) {
try {
@ -38,4 +47,16 @@ public class ShelterApplication extends Application {
mShelterServiceConnection = null;
}
public void unbindFileShuttleService() {
if (mFileShuttleServiceConnection != null) {
try {
unbindService(mFileShuttleServiceConnection);
} catch (Exception e) {
// ...
}
}
mFileShuttleServiceConnection = null;
}
}

View file

@ -0,0 +1,117 @@
package net.typeblog.shelter.services;
import android.app.Service;
import android.content.Intent;
import android.os.Environment;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.support.annotation.Nullable;
import android.webkit.MimeTypeMap;
import net.typeblog.shelter.ShelterApplication;
import net.typeblog.shelter.util.CrossProfileDocumentsProvider;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
// A service to forward file information across the profile boundary
public class FileShuttleService extends Service {
public static final long TIMEOUT = 10000;
// Periodic task to stop the service when idle.
// This service does not need to persist.
private Runnable mSuicideTask = this::suicide;
private Handler mHandler = new Handler(Looper.getMainLooper());
private IFileShuttleService.Stub mStub = new IFileShuttleService.Stub() {
@Override
public void ping() {
// Dummy method
resetSuicideTask();
}
@Override
public List<Map> loadFiles(String path) {
resetSuicideTask();
ArrayList<Map> ret = new ArrayList<>();
File f = new File(resolvePath(path));
if (f.listFiles() != null) {
for (File child : f.listFiles()) {
ret.add(loadFileMeta(child.getPath()));
}
}
return ret;
}
@Override
public Map loadFileMeta(String path) {
resetSuicideTask();
File f = new File(resolvePath(path));
HashMap<String, Object> map = new HashMap<>();
map.put(DocumentsContract.Document.COLUMN_DOCUMENT_ID, f.getAbsolutePath());
map.put(DocumentsContract.Document.COLUMN_DISPLAY_NAME, f.getName());
map.put(DocumentsContract.Document.COLUMN_SIZE, f.length());
map.put(DocumentsContract.Document.COLUMN_LAST_MODIFIED, f.lastModified());
map.put(DocumentsContract.Document.COLUMN_FLAGS, 0);
if (f.isDirectory()) {
map.put(DocumentsContract.Document.COLUMN_MIME_TYPE, DocumentsContract.Document.MIME_TYPE_DIR);
} else {
String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
MimeTypeMap.getFileExtensionFromUrl("file://" + f.getAbsolutePath()));
map.put(DocumentsContract.Document.COLUMN_MIME_TYPE, mime);
}
return map;
}
@Override
public ParcelFileDescriptor openFile(String path, String mode) {
resetSuicideTask();
File f = new File(resolvePath(path));
try {
return ParcelFileDescriptor.open(f, ParcelFileDescriptor.parseMode(mode));
} catch (FileNotFoundException e) {
return null;
}
}
};
@Nullable
@Override
public IBinder onBind(Intent intent) {
resetSuicideTask();
return mStub;
}
@Override
public void onDestroy() {
super.onDestroy();
android.util.Log.d("FileShuttleService", "being destroyed");
}
private String resolvePath(String path) {
if (path.startsWith(CrossProfileDocumentsProvider.DUMMY_ROOT)) {
return path.replaceFirst(CrossProfileDocumentsProvider.DUMMY_ROOT,
Environment.getExternalStorageDirectory().getAbsolutePath());
} else {
return path;
}
}
private void resetSuicideTask() {
mHandler.removeCallbacks(mSuicideTask);
mHandler.postDelayed(mSuicideTask, TIMEOUT);
}
private void suicide() {
mHandler.removeCallbacks(mSuicideTask);
((ShelterApplication) getApplication()).unbindFileShuttleService();
stopSelf();
}
}

View file

@ -1,23 +1,29 @@
package net.typeblog.shelter.ui;
import android.Manifest;
import android.app.Activity;
import android.app.admin.DevicePolicyManager;
import android.content.ComponentName;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.PackageManager;
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.NonNull;
import android.support.annotation.Nullable;
import android.widget.Toast;
import net.typeblog.shelter.R;
import net.typeblog.shelter.ShelterApplication;
import net.typeblog.shelter.receivers.ShelterDeviceAdminReceiver;
import net.typeblog.shelter.services.FileShuttleService;
import net.typeblog.shelter.services.IAppInstallCallback;
import net.typeblog.shelter.services.IFileShuttleService;
import net.typeblog.shelter.services.IFileShuttleServiceCallback;
import net.typeblog.shelter.util.FileProviderProxy;
import net.typeblog.shelter.util.LocalStorageManager;
import net.typeblog.shelter.util.Utility;
@ -39,8 +45,15 @@ public class DummyActivity extends Activity {
public static final String PUBLIC_UNFREEZE_AND_LAUNCH = "net.typeblog.shelter.action.PUBLIC_UNFREEZE_AND_LAUNCH";
public static final String PUBLIC_FREEZE_ALL = "net.typeblog.shelter.action.PUBLIC_FREEZE_ALL";
public static final String FREEZE_ALL_IN_LIST = "net.typeblog.shelter.action.FREEZE_ALL_IN_LIST";
// If we use the same intent for parent -> profile and profile -> parent, the user will
// be prompted with the action chooser with only one choice in it when the intent is
// forwarded by Utility.transferIntentToProfile()
// This is a bad experience, so we use two to avoid this.
public static final String START_FILE_SHUTTLE = "net.typeblog.shelter.action.START_FILE_SHUTTLE";
public static final String START_FILE_SHUTTLE_2 = "net.typeblog.shelter.action.START_FILE_SHUTTLE_2";
private static final int REQUEST_INSTALL_PACKAGE = 1;
private static final int REQUEST_PERMISSION_EXTERNAL_STORAGE= 2;
private boolean mIsProfileOwner = false;
private DevicePolicyManager mPolicyManager = null;
@ -78,6 +91,8 @@ public class DummyActivity extends Activity {
actionPublicFreezeAll();
} else if (FREEZE_ALL_IN_LIST.equals(intent.getAction())) {
actionFreezeAllInList();
} else if (START_FILE_SHUTTLE.equals(intent.getAction()) || START_FILE_SHUTTLE_2.equals(intent.getAction())) {
actionStartFileShuttle();
} else {
finish();
}
@ -92,6 +107,19 @@ public class DummyActivity extends Activity {
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
if (requestCode == REQUEST_PERMISSION_EXTERNAL_STORAGE) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
doStartFileShuttle();
} else {
finish();
}
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
}
private void actionFinalizeProvision() {
if (mIsProfileOwner) {
// This is the action used by DeviceAdminReceiver to finalize the setup
@ -271,4 +299,36 @@ public class DummyActivity extends Activity {
finish();
}
}
private void actionStartFileShuttle() {
// This requires the permission WRITE_EXTERNAL_STORAGE
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
doStartFileShuttle();
} else {
requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUEST_PERMISSION_EXTERNAL_STORAGE);
}
}
private void doStartFileShuttle() {
((ShelterApplication) getApplication()).bindFileShuttleService(new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
IFileShuttleService shuttle = IFileShuttleService.Stub.asInterface(service);
IFileShuttleServiceCallback callback = IFileShuttleServiceCallback.Stub.asInterface(
getIntent().getBundleExtra("extra").getBinder("callback"));
try {
callback.callback(shuttle);
} catch (RemoteException e) {
// Do Nothing
}
finish();
}
@Override
public void onServiceDisconnected(ComponentName name) {
// Do Nothing
}
});
}
}

View file

@ -0,0 +1,174 @@
package net.typeblog.shelter.util;
import android.content.Intent;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.Handler;
import android.os.Looper;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
import android.provider.DocumentsContract;
import android.provider.DocumentsProvider;
import android.support.annotation.Nullable;
import net.typeblog.shelter.R;
import net.typeblog.shelter.services.FileShuttleService;
import net.typeblog.shelter.services.IFileShuttleService;
import net.typeblog.shelter.services.IFileShuttleServiceCallback;
import net.typeblog.shelter.ui.DummyActivity;
import java.util.List;
import java.util.Map;
// A document provider to show files across the profile boundary
// in the system's Documents UI.
// This is an interface to FileShuttleService
public class CrossProfileDocumentsProvider extends DocumentsProvider {
// The dummy root path that will be replaced by the real path to external storage on the other side
public static final String DUMMY_ROOT = "/shelter_storage_root/";
private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_DOCUMENT_ID, DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE, DocumentsContract.Root.COLUMN_FLAGS
};
private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_FLAGS, DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE, DocumentsContract.Document.COLUMN_LAST_MODIFIED
};
private IFileShuttleService mService = null;
private Handler mHandler = new Handler(Looper.getMainLooper());
// Periodic task to release the handle to the service
// Since DocumentsProvider may persist for a long time,
// We just release the service when idle, thus enabling the
// system to release memory
private Runnable mReleaseServiceTask = this::releaseService;
private Object mLock = new Object();
private void doBindService() {
// Call DummyActivity on the other side to bind the service for us
Intent intent = new Intent(DummyActivity.START_FILE_SHUTTLE);
Bundle extra = new Bundle();
extra.putBinder("callback", new IFileShuttleServiceCallback.Stub() {
@Override
public void callback(IFileShuttleService service) {
mService = service;
synchronized (mLock) {
mLock.notifyAll();
}
}
});
intent.putExtra("extra", extra);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
Utility.transferIntentToProfile(getContext(), intent);
} catch (IllegalStateException e) {
// Try with the other action.
// We use distinct intent for parent -> profile and profile -> parent,
// to avoid the action chooser dialog
// so as a dirty hack here, we just try the other if one is not found.
intent.setAction(DummyActivity.START_FILE_SHUTTLE_2);
Utility.transferIntentToProfile(getContext(), intent);
}
getContext().startActivity(intent);
// A hack to convert the asynchronous process of starting service to synchronous
synchronized (mLock) {
try {
mLock.wait();
} catch (InterruptedException e) {
// ???
}
}
}
private void ensureServiceBound() {
if (mService == null) {
doBindService();
} else {
try {
mService.ping();
resetReleaseService();
} catch (RemoteException e) {
doBindService();
}
}
}
private void releaseService() {
mService = null;
}
private void resetReleaseService() {
mHandler.removeCallbacks(mReleaseServiceTask);
mHandler.postDelayed(mReleaseServiceTask, FileShuttleService.TIMEOUT / 2);
}
@Override
public boolean onCreate() {
return true;
}
@Override
public Cursor queryRoots(String[] projection) {
final MatrixCursor result = new MatrixCursor(DEFAULT_ROOT_PROJECTION);
final MatrixCursor.RowBuilder row = result.newRow();
row.add(DocumentsContract.Root.COLUMN_ROOT_ID, DUMMY_ROOT);
row.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, DUMMY_ROOT);
row.add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher_egg);
row.add(DocumentsContract.Root.COLUMN_TITLE, getContext().getString(R.string.app_name));
row.add(DocumentsContract.Root.COLUMN_FLAGS, 0);
return result;
}
@Override
public Cursor queryDocument(String documentId, String[] projection) {
ensureServiceBound();
final MatrixCursor result = new MatrixCursor(DEFAULT_DOCUMENT_PROJECTION);
Map<String, Object> fileInfo = null;
try {
fileInfo = mService.loadFileMeta(documentId);
} catch (RemoteException e) {
return null;
}
includeFile(result, fileInfo);
return result;
}
@Override
public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) {
ensureServiceBound();
List<Map<String, Object>> files = null;
try {
files = mService.loadFiles(parentDocumentId);
} catch (RemoteException e) {
return null;
}
final MatrixCursor result = new MatrixCursor(DEFAULT_DOCUMENT_PROJECTION);
for (Map<String, Object> file : files) {
includeFile(result, file);
}
return result;
}
@Override
public ParcelFileDescriptor openDocument(String documentId, String mode, @Nullable CancellationSignal signal) {
ensureServiceBound();
try {
return mService.openFile(documentId, mode);
} catch (RemoteException e) {
return null;
}
}
private void includeFile(MatrixCursor cursor, Map<String, Object> fileInfo) {
final MatrixCursor.RowBuilder row = cursor.newRow();
for (String col : DEFAULT_DOCUMENT_PROJECTION) {
row.add(col, fileInfo.get(col));
}
}
}

View file

@ -106,6 +106,16 @@ public class Utility {
new IntentFilter(DummyActivity.FINALIZE_PROVISION),
DevicePolicyManager.FLAG_PARENT_CAN_ACCESS_MANAGED);
manager.addCrossProfileIntentFilter(
adminComponent,
new IntentFilter(DummyActivity.START_FILE_SHUTTLE),
DevicePolicyManager.FLAG_MANAGED_CAN_ACCESS_PARENT);
manager.addCrossProfileIntentFilter(
adminComponent,
new IntentFilter(DummyActivity.START_FILE_SHUTTLE_2),
DevicePolicyManager.FLAG_PARENT_CAN_ACCESS_MANAGED);
// Allow ACTION_SEND and ACTION_SEND_MULTIPLE to cross from managed to parent
// TODO: Make this configurable
IntentFilter actionSendFilter = new IntentFilter();