blob: 975aae2856f9b50178146b66bccf4e6e0cb4866e [file] [log] [blame]
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.libraries.mobiledatadownload.file.backends;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.os.Environment;
import android.os.StatFs;
import android.os.SystemClock;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* Provides access to high-level information about the Android file environment. These utilities are
* neither intended nor available for use outside of the MobStore library implementation.
*/
public final class AndroidFileEnvironment {
private static final String TAG = "AndroidFileEnvironment";
/** Returns all {@code dirs} that are currently mounted with full read/write access. */
public static List<File> getMountedExternalDirs(List<File> dirs) {
List<File> result = new ArrayList<>();
for (File dir : dirs) {
if (dir == null) {
continue;
}
String state = getStorageState(dir);
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, String.format("External storage: [%s] is [%s]", dir.getAbsolutePath(), state));
}
if (Environment.MEDIA_MOUNTED.equals(state)) {
result.add(dir);
}
}
return result;
}
/**
* Returns the current state of the shared/external storage media at the given path. This is a
* private API to support {@link Environment#getStorageState(File)} across all sdk levels.
*/
private static String getStorageState(File dir) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
return getStorageStateKK(dir);
} else {
return getStorageStateICS(dir);
}
}
/** Private API to support {@link #getStorageState} on sdk KK and higher. */
@TargetApi(Build.VERSION_CODES.KITKAT)
private static String getStorageStateKK(File dir) {
return Environment.getStorageState(dir);
}
/** Private API to support {@link #getStorageState} on lower sdk levels. */
private static String getStorageStateICS(File dir) {
// Implementation taken directly from EnvironmentCompat#getStorageState. Note that JB and below
// only support one external storage partition, thus can only return a meaningful value for a
// directory under that partition.
try {
String dirPath = dir.getCanonicalPath();
String externalPath = Environment.getExternalStorageDirectory().getCanonicalPath();
if (dirPath.startsWith(externalPath)) {
return Environment.getExternalStorageState();
}
} catch (IOException e) {
Log.w(TAG, "Failed to resolve canonical path", e);
}
return "unknown"; // == Environment.MEDIA_UNKNOWN, which isn't available below KK
}
/**
* Returns all available non-emulated external cache directories. This method does not guarantee
* that the returned paths are mounted.
*/
public static List<File> getNonEmulatedExternalCacheDirs(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
return getNonEmulatedExternalCacheDirsLP(context);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
return getNonEmulatedExternalCacheDirsKK(context);
} else {
return getNonEmulatedExternalCacheDirsICS(context);
}
}
/**
* Private API; please use {@link #getNonEmulatedExternalCacheDirs} directly. This implementation
* of {@link #getNonEmulatedExternalCacheDirs} uses the new APIs available on LOLLIPOP and later
* in order to query each external storage partition for emulation.
*/
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private static List<File> getNonEmulatedExternalCacheDirsLP(Context context) {
List<File> result = new ArrayList<>();
for (File dir : Arrays.asList(context.getExternalCacheDirs())) {
try {
if (dir != null && !Environment.isExternalStorageEmulated(dir)) {
result.add(dir);
}
} catch (IllegalArgumentException e) {
// NOTE: on some devices and API levels, Environment.isExternalStorageEmulated(File)
// will throw an exception if the partition is not mounted. In any case this means the dir
// is unavailable, so we can continue past it. See b/29833349 for more info.
// TODO(b/64078707): enable Robolectric to throw exceptions here to increase test coverage
Log.w(
TAG,
String.format("isExternalStorageEmulated(File) failed for [%s]", dir.getAbsolutePath()),
e);
continue;
}
}
return result;
}
/**
* Private API; please use {@link #getNonEmulatedExternalCacheDirs} directly. This implementation
* of {@link #getNonEmulatedExternalCacheDirs} supports lower SDK levels and can't query secondary
* partitions for emulation. However, only the primary partition can be emulated on such devices.
*/
@TargetApi(Build.VERSION_CODES.KITKAT)
private static List<File> getNonEmulatedExternalCacheDirsKK(Context context) {
List<File> result = new ArrayList<>();
// If the primary external storage is non-emulated, return it
File[] dirs = context.getExternalCacheDirs();
if (!Environment.isExternalStorageEmulated() && dirs[0] != null) {
result.add(dirs[0]);
}
// Check secondary storage. We skip the first dir (primary), which we already checked. Secondary
// dirs cannot be explicitly checked for emulation because of the API level, but are assumed
// to be non-emulated. See {@link https://source.android.com/devices/storage/config-example} and
// {@link com.google.android.apps.gmm.shared.util.FileUtil#getNonEmulatedExternalFilesDirKK}.
for (int i = 1; i < dirs.length; i++) {
if (dirs[i] != null) {
result.add(dirs[i]);
}
}
return result;
}
/**
* Private API; please use {@link #getNonEmulatedExternalCacheDirs} directly. This implementation
* supports sdk levels below KitKat, and due to the limited API can only return a single external
* storage partition (which may be emulated, in which case none are returned).
*/
private static List<File> getNonEmulatedExternalCacheDirsICS(Context context) {
File dir = context.getExternalCacheDir();
if (!Environment.isExternalStorageEmulated() && dir != null) {
return Arrays.asList(dir);
}
return Collections.emptyList();
}
/** Returns the number of bytes free and available on the file system of {@code dir}. */
public static long getAvailableStorageSpace(File dir) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
return getAvailableStorageSpaceJBMR2(dir);
} else {
return getAvailableStorageSpaceICS(dir);
}
}
/**
* Based on cl/189267818. Paraphrased here:
*
* <p>According to AGSA bug b/30959609 and similar bugs in other 1st party apps, the Context can
* return a null filesDir on SDK versions before N. The root cause is a race condition between two
* threads that try to initialize the application's directory structure immediately following
* installation. One thread waits, and the other returns a null File pointer. The bug is fixed in
* Android N. The workaround for older releases is to wait. A short while after failing, the
* directory structure is initialized, and the previously failing Context returns a valid File
* pointer. If that doesn't work, then the Context must be broken for other reasons. We throw an
* IllegalStateException in this case.
*/
// TODO(b/70255835): rename to not suggest N is safe since bug affects up to and including sdk N
public static File getFilesDirWithPreNWorkaround(Context context) {
File filesDir = context.getFilesDir();
// According to Android docs, this can't happen, but a pre-N bug makes this sometimes return
// null. See b/30959609 for details.
if (filesDir == null) {
// The cause is an internal race condition. Sleep and try again.
SystemClock.sleep(100);
filesDir = context.getFilesDir();
if (filesDir == null) {
throw new IllegalStateException("getFilesDir returned null twice.");
}
}
return filesDir;
}
/** Private API to support {@link #getAvailableStorageSpace} on sdk JB-MR2 and higher. */
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
private static long getAvailableStorageSpaceJBMR2(File dir) {
StatFs stat = new StatFs(dir.getPath());
return stat.getAvailableBytes();
}
/** Private API to support {@link #getAvailableStorageSpace} on lower sdk levels. */
private static long getAvailableStorageSpaceICS(File dir) {
StatFs stat = new StatFs(dir.getPath());
return (long) stat.getBlockSize() * stat.getAvailableBlocks();
}
/**
* Returns the data directory of {@code context} in the DirectBoot storage partition. Each call to
* this method creates a new instance of {@link Context}, so the result should reused if possible.
*/
@TargetApi(Build.VERSION_CODES.N)
public static File getDeviceProtectedDataDir(Context context) {
Context dpsContext = context.createDeviceProtectedStorageContext();
File dpsFilesDir = getFilesDirWithPreNWorkaround(dpsContext);
File dpsDataDir = dpsFilesDir.getParentFile();
return dpsDataDir;
}
private AndroidFileEnvironment() {}
}