Java tutorial
/* * Copyright 2017 Alexander Rvachev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package net.sf.xfd.provider; import android.annotation.SuppressLint; import android.content.ClipDescription; import android.content.ComponentName; import android.content.ContentProvider; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.res.AssetFileDescriptor; import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; import android.os.Binder; import android.os.Bundle; import android.os.CancellationSignal; import android.os.Handler; import android.os.Looper; import android.os.ParcelFileDescriptor; import android.os.Process; import android.provider.BaseColumns; import android.provider.DocumentsContract; import android.provider.MediaStore; import android.provider.OpenableColumns; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.os.ResultReceiver; import android.support.v4.util.LruCache; import android.text.TextUtils; import android.util.Base64; import com.carrotsearch.hppc.ObjectIntHashMap; import com.carrotsearch.hppc.ObjectIntMap; import net.sf.xfd.DirFd; import net.sf.xfd.Fd; import net.sf.xfd.LogUtil; import net.sf.xfd.NativeBits; import net.sf.xfd.OS; import net.sf.xfd.Stat; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.security.InvalidKeyException; import java.security.Key; import java.security.NoSuchAlgorithmException; import java.util.Calendar; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import javax.crypto.KeyGenerator; import javax.crypto.Mac; import static android.content.ContentResolver.SCHEME_CONTENT; import static android.os.ParcelFileDescriptor.MODE_READ_ONLY; import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY; import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME; import static android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE; import static android.provider.DocumentsContract.Document.COLUMN_SIZE; import static android.provider.DocumentsContract.Document.MIME_TYPE_DIR; import static android.util.Base64.NO_PADDING; import static android.util.Base64.NO_WRAP; import static android.util.Base64.URL_SAFE; import static android.util.Base64.encodeToString; import static net.sf.xfd.provider.PermissionDelegate.ACTION_PERMISSION_REQUEST; import static net.sf.xfd.provider.PermissionDelegate.EXTRA_CALLBACK; import static net.sf.xfd.provider.PermissionDelegate.EXTRA_CALLER; import static net.sf.xfd.provider.PermissionDelegate.EXTRA_MODE; import static net.sf.xfd.provider.PermissionDelegate.EXTRA_PATH; import static net.sf.xfd.provider.PermissionDelegate.EXTRA_RESPONSE; import static net.sf.xfd.provider.PermissionDelegate.EXTRA_UID; import static net.sf.xfd.provider.PermissionDelegate.META_IS_PERMISSION_DELEGATE; import static net.sf.xfd.provider.PermissionDelegate.RESPONSE_ALLOW; import static net.sf.xfd.provider.PermissionDelegate.RESPONSE_DENY; import static net.sf.xfd.provider.ProviderBase.*; import static net.sf.xfd.provider.ProviderBase.assertAbsolute; import static net.sf.xfd.provider.ProviderBase.canonString; import static net.sf.xfd.provider.ProviderBase.extractName; @SuppressLint("InlinedApi") public final class PublicProvider extends ContentProvider { public static final String AUTHORITY_SUFFIX = ".public_provider"; private static final String COOKIE_FILE = "key"; private static final int COOKIE_SIZE = 20; public static final String URI_ARG_TYPE = "t"; public static final String URI_ARG_EXPIRY = "e"; public static final String URI_ARG_COOKIE = "c"; public static final String URI_ARG_MODE = "m"; private static volatile Intent authActivity; private final Lock uxLock = new ReentrantLock(); private static ComponentName createRelative(String pkg, String cls) { final String fullName; if (cls.charAt(0) == '.') { // Relative to the package. Prepend the package name. fullName = pkg + cls; } else { // Fully qualified package name. fullName = cls; } return new ComponentName(pkg, fullName); } private static @Nullable Intent authActivityIntent(Context c) { if (authActivity == null) { synchronized (PublicProvider.class) { if (authActivity == null) { final PackageManager pm = c.getPackageManager(); final String packageName = c.getPackageName(); try { final PackageInfo pi = pm.getPackageInfo(packageName, PackageManager.GET_ACTIVITIES | PackageManager.GET_META_DATA); for (ActivityInfo activity : pi.activities) { final Bundle metadata = activity.metaData; if (metadata != null) { boolean isSuitable = metadata.getBoolean(META_IS_PERMISSION_DELEGATE); if (isSuitable) { authActivity = new Intent(ACTION_PERMISSION_REQUEST) .setComponent(createRelative(packageName, activity.name)) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); break; } } } } catch (Exception e) { return null; } } } } return authActivity; } private static volatile Key cookieSalt; private static @Nullable Key getSalt(Context c) { if (cookieSalt == null) { synchronized (PublicProvider.class) { if (cookieSalt == null) { try { try (ObjectInputStream oos = new ObjectInputStream(c.openFileInput(COOKIE_FILE))) { cookieSalt = (Key) oos.readObject(); } catch (ClassNotFoundException | IOException e) { LogUtil.logCautiously("Unable to read key file, probably corrupted or missing", e); final File corrupted = c.getFileStreamPath(COOKIE_FILE); //noinspection ResultOfMethodCallIgnored corrupted.delete(); } if (cookieSalt != null) { return cookieSalt; } final KeyGenerator keygen = KeyGenerator.getInstance("HmacSHA1"); keygen.init(COOKIE_SIZE * Byte.SIZE); cookieSalt = keygen.generateKey(); try (ObjectOutputStream oos = new ObjectOutputStream( c.openFileOutput(COOKIE_FILE, Context.MODE_PRIVATE))) { oos.writeObject(cookieSalt); } catch (IOException e) { LogUtil.logCautiously("Failed to save key file", e); return null; } } catch (NoSuchAlgorithmException e) { throw new AssertionError("failed to initialize hash functions", e); } } } } return cookieSalt; } private final String[] COMMON_PROJECTION = new String[] { BaseColumns._ID, OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE, MediaStore.MediaColumns.MIME_TYPE, }; private final LruCache<String, ObjectIntMap<String>> accessCache = new LruCache<>(100); private ProviderBase base; @Override public boolean onCreate() { final Context context = getContext(); assert context != null; final String packageName = context.getPackageName(); String authority = packageName + AUTHORITY_SUFFIX; try { base = new ProviderBase(getContext(), authority); } catch (IOException e) { e.printStackTrace(); return false; } return true; } @Nullable @Override public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { String path = uri.getPath(); if (TextUtils.isEmpty(uri.getPath())) { path = "/"; } try { assertAbsolute(path); } catch (FileNotFoundException e) { return null; } path = canonString(path); if (!path.equals(uri.getPath())) { uri = uri.buildUpon().path(path).build(); } if (!checkAccess(uri, "r")) { return null; } if (projection == null) { projection = COMMON_PROJECTION; } final OS os = base.getOS(); if (os == null) { return null; } try { final MatrixCursor cursor = new MatrixCursor(projection, 1); final Object[] row = new Object[projection.length]; final Stat stat = new Stat(); final String name = extractName(path); final String mime = base.getTypeFast(path, name, stat); for (int i = 0; i < projection.length; ++i) { String col = projection[i]; switch (col) { case BaseColumns._ID: row[i] = stat.st_ino; break; case COLUMN_DISPLAY_NAME: row[i] = name; break; case COLUMN_SIZE: row[i] = stat.st_size; break; case COLUMN_MIME_TYPE: row[i] = mime; break; default: row[i] = null; } } cursor.addRow(row); final Context context = getContext(); assert context != null; final String packageName = context.getPackageName(); cursor.setNotificationUri(context.getContentResolver(), DocumentsContract.buildDocumentUri(packageName + FileProvider.AUTHORITY_SUFFIX, path)); return cursor; } catch (IOException e) { e.printStackTrace(); return null; } } @Nullable public String getType(@NonNull Uri uri) { final String hardCodedType = uri.getQueryParameter(URI_ARG_TYPE); if (hardCodedType != null) { return hardCodedType.isEmpty() ? null : hardCodedType; } try { assertAbsolute(uri.getPath()); final String path = uri.getPath(); final String name = extractName(path); return base.getTypeFast(path, name, new Stat()); } catch (IOException e) { return null; } } @Nullable @Override public String[] getStreamTypes(@NonNull Uri uri, @NonNull String mimeTypeFilter) { final String hardCodedType = uri.getQueryParameter(URI_ARG_TYPE); if (hardCodedType != null) { if (hardCodedType.isEmpty()) return null; if (mimeTypeMatches(mimeTypeFilter, hardCodedType)) { return new String[] { hardCodedType }; } } try { assertAbsolute(uri.getPath()); } catch (FileNotFoundException e) { return null; } return base.getStreamTypes(uri.getPath(), mimeTypeFilter); } final boolean checkAccess(Uri uri, String necessaryMode) { String grantMode = uri.getQueryParameter(URI_ARG_MODE); if (TextUtils.isEmpty(grantMode)) { grantMode = "r"; } return checkAccess(uri, grantMode, necessaryMode); } final boolean checkAccess(Uri uri, String grantMode, String necessaryMode) { try { verifyMac(uri, grantMode, necessaryMode); return true; } catch (FileNotFoundException fnfe) { final Context context = getContext(); assert context != null; ObjectIntMap<String> decisions = null; final String caller = getCallingPackage(); if (!TextUtils.isEmpty(caller)) { decisions = accessCache.get(uri.getPath()); if (decisions == null) { decisions = new ObjectIntHashMap<>(); } else { //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (decisions) { final int decision = decisions.get(caller); switch (decision) { case RESPONSE_ALLOW: return true; } } } } final ArrayBlockingQueue<Bundle> queue = new ArrayBlockingQueue<>(1); //noinspection RestrictedApi final ResultReceiver receiver = new ResultReceiver(new Handler(Looper.getMainLooper())) { @Override protected void onReceiveResult(int resultCode, Bundle resultData) { try { queue.offer(resultData, 4, TimeUnit.SECONDS); } catch (InterruptedException ignored) { } } }; try { final Intent intent = authActivityIntent(context); if (intent == null) return false; final Bundle result; final CharSequence resolved = base.resolve(uri.getPath()); // try to ensure, that no more than one dialog can appear at once uxLock.lockInterruptibly(); try { context.startActivity(intent.putExtra(EXTRA_MODE, necessaryMode).putExtra(EXTRA_CALLER, caller) .putExtra(EXTRA_UID, Binder.getCallingUid()).putExtra(EXTRA_CALLBACK, receiver) .putExtra(EXTRA_PATH, resolved)); result = queue.poll(10, TimeUnit.SECONDS); } finally { uxLock.unlock(); } int decision = RESPONSE_DENY; if (result != null) { decision = result.getInt(EXTRA_RESPONSE, -1); } if (decision == RESPONSE_ALLOW) { if (decisions != null) { //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (decisions) { decisions.put(caller, RESPONSE_ALLOW); accessCache.put(uri.getPath(), decisions); } } return true; } } catch (InterruptedException ignored) { } } return false; } final void verifyMac(Uri path, String grantMode, String requested) throws FileNotFoundException { if (Process.myUid() == Binder.getCallingUid()) { return; } final int requestedMode = ParcelFileDescriptor.parseMode(requested); final String cookie = path.getQueryParameter(URI_ARG_COOKIE); final String expiry = path.getQueryParameter(URI_ARG_EXPIRY); if (TextUtils.isEmpty(cookie) || TextUtils.isEmpty(expiry)) { throw new FileNotFoundException("Invalid uri: MAC and expiry date are missing"); } final long l; try { l = Long.parseLong(expiry); } catch (NumberFormatException nfe) { throw new FileNotFoundException("Invalid uri: unable to parse expiry date"); } final Key key = getSalt(getContext()); if (key == null) { throw new FileNotFoundException("Unable to verify hash: failed to produce key"); } final int modeInt = ParcelFileDescriptor.parseMode(grantMode); if ((requestedMode & modeInt) != requestedMode) { throw new FileNotFoundException("Requested mode " + requested + " but limited to " + grantMode); } final byte[] encoded; final Mac hash; try { hash = Mac.getInstance("HmacSHA1"); hash.init(key); final byte[] modeBits = new byte[] { (byte) (modeInt >> 24), (byte) (modeInt >> 16), (byte) (modeInt >> 8), (byte) modeInt, }; hash.update(modeBits); final byte[] expiryDate = new byte[] { (byte) (l >> 56), (byte) (l >> 48), (byte) (l >> 40), (byte) (l >> 32), (byte) (l >> 24), (byte) (l >> 16), (byte) (l >> 8), (byte) l, }; hash.update(expiryDate); encoded = hash.doFinal(path.getPath().getBytes()); final String sample = Base64.encodeToString(encoded, URL_SAFE | NO_WRAP | NO_PADDING); if (!cookie.equals(sample)) { throw new FileNotFoundException("Expired uri"); } } catch (NoSuchAlgorithmException e) { throw new FileNotFoundException("Unable to verify hash: missing HmacSHA1"); } catch (InvalidKeyException e) { throw new FileNotFoundException("Unable to verify hash: corrupted key?!"); } } @Nullable @Override public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { return openFile(uri, mode, null); } @Nullable @Override public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String requestedMode, CancellationSignal signal) throws FileNotFoundException { String path = uri.getPath(); assertAbsolute(path); final int readableMode = ParcelFileDescriptor.parseMode(requestedMode); if (signal != null) { final Thread theThread = Thread.currentThread(); signal.setOnCancelListener(theThread::interrupt); } path = canonString(path); if (!path.equals(uri.getPath())) { uri = uri.buildUpon().path(path).build(); } try { if (!checkAccess(uri, requestedMode)) { return null; } final OS rooted = base.getOS(); if (rooted == null) { throw new FileNotFoundException("Failed to open " + uri.getPath() + ": unable to acquire access"); } int openFlags; if ((readableMode & MODE_READ_ONLY) == readableMode) { openFlags = OS.O_RDONLY; } else if ((readableMode & MODE_WRITE_ONLY) == readableMode) { openFlags = OS.O_WRONLY; } else { openFlags = OS.O_RDWR; } if (signal == null) { openFlags |= NativeBits.O_NONBLOCK; } //noinspection WrongConstant @Fd int fd = rooted.open(path, openFlags, 0); return ParcelFileDescriptor.adoptFd(fd); } catch (IOException e) { throw new FileNotFoundException("Unable to open " + uri.getPath() + ": " + e.getMessage()); } finally { if (signal != null) { signal.setOnCancelListener(null); } Thread.interrupted(); } } @Nullable @Override public AssetFileDescriptor openAssetFile(@NonNull Uri uri, @NonNull String mode, @Nullable CancellationSignal signal) throws FileNotFoundException { ParcelFileDescriptor fd = openFile(uri, mode, signal); return fd != null ? new AssetFileDescriptor(fd, 0, -1) : null; } @Nullable @Override public AssetFileDescriptor openTypedAssetFile(@NonNull Uri uri, @NonNull String mimeTypeFilter, @Nullable Bundle opts, @Nullable CancellationSignal signal) throws FileNotFoundException { if ("*/*".equals(mimeTypeFilter)) { // If they can take anything, the untyped open call is good enough. return openAssetFile(uri, "r", signal); } String[] possibleTypes = getStreamTypes(uri, mimeTypeFilter); if (possibleTypes != null) { for (String possibleType : possibleTypes) { if (ClipDescription.compareMimeTypes(possibleType, mimeTypeFilter)) { // Use old untyped open call if this provider has a type for this // URI and it matches the request. return openAssetFile(uri, "r", signal); } } } throw new FileNotFoundException("Can't open " + uri + " as type " + mimeTypeFilter); } @Override public Uri canonicalize(@NonNull Uri uri) { try { base.assertAbsolute(uri); String grantMode = uri.getQueryParameter(URI_ARG_MODE); if (TextUtils.isEmpty(grantMode)) { grantMode = "r"; } verifyMac(uri, grantMode, grantMode); final int flags = ParcelFileDescriptor.parseMode(grantMode); final Context context = getContext(); assert context != null; final String packageName = context.getPackageName(); final Uri canon = DocumentsContract.buildDocumentUri(packageName + FileProvider.AUTHORITY_SUFFIX, canonString(uri.getPath())); final int callerUid = Binder.getCallingUid(); if (callerUid != Process.myUid()) { int grantFlags = Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION; if ((flags & ParcelFileDescriptor.MODE_READ_ONLY) == flags) { grantFlags |= Intent.FLAG_GRANT_READ_URI_PERMISSION; } else if ((flags & ParcelFileDescriptor.MODE_WRITE_ONLY) == flags) grantFlags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; else { grantFlags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; grantFlags |= Intent.FLAG_GRANT_READ_URI_PERMISSION; } final String[] packages; final String caller = getCallingPackage(); if (caller != null) { packages = new String[] { caller }; } else { final PackageManager pm = context.getPackageManager(); packages = pm.getPackagesForUid(callerUid); } if (packages != null) { for (String pkg : packages) { context.grantUriPermission(pkg, canon, grantFlags); } } } return canon; } catch (FileNotFoundException e) { return null; } } @Nullable @Override public Uri insert(@NonNull Uri uri, ContentValues values) { return null; } @Override public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { try { assertAbsolute(uri.getPath()); } catch (FileNotFoundException e) { return 0; } if (!checkAccess(uri, "w")) { return 0; } final OS os = base.getOS(); if (os != null) { final boolean isDir = MIME_TYPE_DIR.equals(getType(uri)); try { os.unlinkat(DirFd.NIL, uri.getPath(), isDir ? OS.AT_REMOVEDIR : 0); return 1; } catch (IOException e) { LogUtil.logCautiously("Failed to unlink", e); } } return 0; } @Override public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { return 0; } public static @Nullable Uri publicUri(Context context, CharSequence path) { return publicUri(context, path, "r"); } public static @Nullable Uri publicUri(Context context, CharSequence path, String mode) { // XXX suspect coversion final String pathString = path.toString(); final int modeInt = ParcelFileDescriptor.parseMode(mode); final Key key = getSalt(context); if (key == null) { return null; } final Calendar c = Calendar.getInstance(); c.add(Calendar.DATE, 1); final long l = c.getTimeInMillis(); final byte[] encoded; try { final Mac hash = Mac.getInstance("HmacSHA1"); hash.init(key); final byte[] modeBits = new byte[] { (byte) (modeInt >> 24), (byte) (modeInt >> 16), (byte) (modeInt >> 8), (byte) modeInt, }; hash.update(modeBits); final byte[] expiryDate = new byte[] { (byte) (l >> 56), (byte) (l >> 48), (byte) (l >> 40), (byte) (l >> 32), (byte) (l >> 24), (byte) (l >> 16), (byte) (l >> 8), (byte) l, }; hash.update(expiryDate); encoded = hash.doFinal(pathString.getBytes()); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new AssertionError("Error while creating a hash: " + e.getMessage(), e); } final String packageName = context.getPackageName(); final Uri.Builder b = new Uri.Builder().scheme(SCHEME_CONTENT).authority(packageName + AUTHORITY_SUFFIX); if (!"r".equals(mode)) { b.appendQueryParameter(URI_ARG_MODE, mode); } return b.path(pathString).appendQueryParameter(URI_ARG_EXPIRY, String.valueOf(l)) .appendQueryParameter(URI_ARG_COOKIE, encodeToString(encoded, URL_SAFE | NO_WRAP | NO_PADDING)) .build(); } }