From 58fc86f93e762a14463d8e8727b90d031b6338a6 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Wed, 19 Sep 2018 12:23:38 +0800 Subject: [PATCH] implement cross-profile DocumentsProvider now we can freely access files across profile through Documents UI --- app/src/main/AndroidManifest.xml | 22 +++ .../shelter/services/IFileShuttleService.aidl | 11 ++ .../services/IFileShuttleServiceCallback.aidl | 8 + .../typeblog/shelter/ShelterApplication.java | 21 +++ .../shelter/services/FileShuttleService.java | 117 ++++++++++++ .../typeblog/shelter/ui/DummyActivity.java | 60 ++++++ .../util/CrossProfileDocumentsProvider.java | 174 ++++++++++++++++++ .../net/typeblog/shelter/util/Utility.java | 10 + 8 files changed, 423 insertions(+) create mode 100644 app/src/main/aidl/net/typeblog/shelter/services/IFileShuttleService.aidl create mode 100644 app/src/main/aidl/net/typeblog/shelter/services/IFileShuttleServiceCallback.aidl create mode 100644 app/src/main/java/net/typeblog/shelter/services/FileShuttleService.java create mode 100644 app/src/main/java/net/typeblog/shelter/util/CrossProfileDocumentsProvider.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dea71b9..1e82231 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + + + + + @@ -78,12 +83,29 @@ android:resource="@xml/file_paths" /> + + + + + + + + + + diff --git a/app/src/main/aidl/net/typeblog/shelter/services/IFileShuttleService.aidl b/app/src/main/aidl/net/typeblog/shelter/services/IFileShuttleService.aidl new file mode 100644 index 0000000..2236803 --- /dev/null +++ b/app/src/main/aidl/net/typeblog/shelter/services/IFileShuttleService.aidl @@ -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); +} diff --git a/app/src/main/aidl/net/typeblog/shelter/services/IFileShuttleServiceCallback.aidl b/app/src/main/aidl/net/typeblog/shelter/services/IFileShuttleServiceCallback.aidl new file mode 100644 index 0000000..ff33f53 --- /dev/null +++ b/app/src/main/aidl/net/typeblog/shelter/services/IFileShuttleServiceCallback.aidl @@ -0,0 +1,8 @@ +// IFileShuttleServiceCallback.aidl +package net.typeblog.shelter.services; + +import net.typeblog.shelter.services.IFileShuttleService; + +interface IFileShuttleServiceCallback { + void callback(in IFileShuttleService service); +} diff --git a/app/src/main/java/net/typeblog/shelter/ShelterApplication.java b/app/src/main/java/net/typeblog/shelter/ShelterApplication.java index 3ec8979..d2fb177 100644 --- a/app/src/main/java/net/typeblog/shelter/ShelterApplication.java +++ b/app/src/main/java/net/typeblog/shelter/ShelterApplication.java @@ -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; + } } diff --git a/app/src/main/java/net/typeblog/shelter/services/FileShuttleService.java b/app/src/main/java/net/typeblog/shelter/services/FileShuttleService.java new file mode 100644 index 0000000..db027fa --- /dev/null +++ b/app/src/main/java/net/typeblog/shelter/services/FileShuttleService.java @@ -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 loadFiles(String path) { + resetSuicideTask(); + ArrayList 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 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(); + } +} diff --git a/app/src/main/java/net/typeblog/shelter/ui/DummyActivity.java b/app/src/main/java/net/typeblog/shelter/ui/DummyActivity.java index 3ebae6c..664d76b 100644 --- a/app/src/main/java/net/typeblog/shelter/ui/DummyActivity.java +++ b/app/src/main/java/net/typeblog/shelter/ui/DummyActivity.java @@ -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 + } + }); + } } diff --git a/app/src/main/java/net/typeblog/shelter/util/CrossProfileDocumentsProvider.java b/app/src/main/java/net/typeblog/shelter/util/CrossProfileDocumentsProvider.java new file mode 100644 index 0000000..2780dc5 --- /dev/null +++ b/app/src/main/java/net/typeblog/shelter/util/CrossProfileDocumentsProvider.java @@ -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 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> files = null; + try { + files = mService.loadFiles(parentDocumentId); + } catch (RemoteException e) { + return null; + } + final MatrixCursor result = new MatrixCursor(DEFAULT_DOCUMENT_PROJECTION); + + for (Map 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 fileInfo) { + final MatrixCursor.RowBuilder row = cursor.newRow(); + for (String col : DEFAULT_DOCUMENT_PROJECTION) { + row.add(col, fileInfo.get(col)); + } + } +} diff --git a/app/src/main/java/net/typeblog/shelter/util/Utility.java b/app/src/main/java/net/typeblog/shelter/util/Utility.java index 7d7b4c1..925222c 100644 --- a/app/src/main/java/net/typeblog/shelter/util/Utility.java +++ b/app/src/main/java/net/typeblog/shelter/util/Utility.java @@ -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();