Android 10 引入了大量变更

重大隐私权变更

隐私权变更 受影响的应用 缓解策略
分区存储 针对外部存储的过滤视图,可提供对特定于应用的文件和媒体集合的访问权限 访问和共享外部存储中的文件的应用 使用特定于应用的目录和媒体集合目录 了解详情
增强了用户对位置权限的控制力 仅限前台权限,可让用户更好地控制应用对设备位置信息的访问权限 在后台时请求访问用户位置信息的应用 确保在没有后台位置信息更新的情况下优雅降级 使用 Android 10 中引入的权限在后台获取位置信息 了解详情
系统执行后台 Activity 针对从后台启动 Activity 实施了限制 不需要用户互动就启动 Activity 的应用 使用通知触发的 Activity 了解详情
不可重置的硬件标识符 针对访问设备序列号和 IMEI 实施了限制 访问设备序列号或 IMEI 的应用 使用用户可以重置的标识符 了解详情
无线扫描权限 访问某些 WLAN、WLAN 感知和蓝牙扫描方法需要获得精确位置权限 使用 WLAN API 和蓝牙 API 的应用 针对相关使用场景请求 ACCESS_FINE_LOCATION 权限 了解详情

外部存储访问权限范围限定为应用文件和媒体

为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被赋予了对外部存储设备的分区访问权限(即分区存储)。此类应用只能看到本应用专有的目录(通过 Context.getExternalFilesDir() 访问)以及特定类型的媒体。除非您的应用需要访问存放在应用的专有目录以及 MediaStore 之外的文件,否则最好使用分区存储。

下表总结了分区存储如何影响文件访问:

文件位置 所需权限 访问方法 (*) 卸载应用时是否移除文件?
特定于应用的目录 getExternalFilesDir()
媒体集合 (照片、视频、音频) READ_EXTERNAL_STORAGE (仅当 访问其他应用的文件时) MediaStore
下载内容 (文档和 电子书籍) 存储访问框架 (加载系统的文件选择器)

*您可以使用存储访问框架访问上表中显示的每一个位置,而无需请求任何权限。

存储的数据库读些和视频文件读写受到影响

解决办法:

数据库存储目录:

修改数据库存储目录变为应用专有的目录(卸载应用,数据库将会被删除)。

final static private String APP_WORKSPACE_PATH = "workspace"; // 应用工作空间目录
public final static String APP_DATABASES_PATH = "databases";
String workspace = APP_WORKSPACE_PATH + File.separator + APP_DATABASES_PATH + File.separator;
workspace=FileManager.getExternalFiresDirRootPath(context) + File.separator + APP_HIDDEN_PATH  + File.separator + workspace;//系统内部存储目录,非外置存储目录

录制保存视频目录

录制保存视频文件放置在应用专有的目录的movie下

public static final  String   SDK_Q_DEFAULT_SAVE_VIDEO_PATH = "/storage/emulated/0" + File.separator + Environment.DIRECTORY_MOVIES + File.separator + FileManager.APP_ROOT_PATH;
public static String getSaveVideoPath(Context context) {
    String path = getPref(context, Constants.KEY_SAVE_VIDEO_PATH);
    if (TextUtils.isEmpty(path)) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            return SDK_Q_DEFAULT_SAVE_VIDEO_PATH;
        } else {
            return context.getResources().getString(R.string.record_video_save_path);
        }
    }
    return path;
}

素材目录:

FileManager.ANDROID_APP_DATA_PRIVATE_PATH = getExternalFilesDir(null).getAbsolutePath();
String fileSavePath = FileManager.ANDROID_APP_DATA_PRIVATE_PATH+File.separator + ".1VRecorder"+File.separator+"theme";

图片缓存目录

/**
     * * @return /{系统外置目录}/Android/data/{packageName}
     */
private static String getExternalFiresDirRootPath() {
        if (VideoEditorApplication.getInstance() != null)
            return VideoEditorApplication.getInstance()
            .getExternalFilesDir(null)
            .getAbsolutePath() + File.separator;
        return "null";
}

// 获得应用根目录
public static String getAppRootPath() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        String rootDir = getExternalFiresDirRootPath()
            + APP_ROOT_PATH + File.separator;
        FileUtil.mkdirs(rootDir);
        return rootDir;
    } else {
        String rootDir = getSystemRootPath() + File.separator
            + APP_ROOT_PATH + File.separator;
        int i = 1;
        while (!FileUtil.mkdirs(rootDir)) {
            i++;
            APP_ROOT_PATH = (APP_ROOT_PATH + "_" + i);
            ReplaceYouMengUtils
                .onEvent(VideoEditorApplication.getInstance()
                         , "MAKE_APP_ROOT_DIR_FAILED");
            rootDir = getSystemRootPath() + File.separator 
                + APP_ROOT_PATH + File.separator;
            if (i >= 3) {
                break;
            }
        }
        return rootDir;
    }
}

   /**
     * 获取隐藏文件路径 .1Videoshow
     *
     * @return
     */
    public static String getVideoShowHidePath() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            return getAppRootPath() + File.separator
                + APP_HIDDEN_PATH + File.separator;
        } else {
            return getSystemRootPath() + File.separator 
                + APP_HIDDEN_PATH + File.separator;
        }
/**
     * 图片缓存路径
     *
     * @return
     */
    public static String getImageCachePath(final String originPath) {
        String filePath = getVideoShowHidePath() + APP_IMAGECACHE_PATH + File.separator;
        FileUtil.mkdirs(filePath);
        TimeUtil.setLastTimeMillis();
        int defaultWidth = FxConfig.getDefaultCachePictrueMaxWH(true);
        String fileName = MD5Util.getMD5Str(originPath, null) + "." + defaultWidth + "."
                + FileUtil.getExtensionName(originPath); 
    
        filePath += fileName;
        return filePath;

更多路径修改:请查看FileManager.java文件代码,和SettingFragment的保存视频路径的改动代码

Android 10 录制视频创建视频方式

通过MediaStore API 存储文件:获得保存文件URI,然后获得parcelFileDescriptor传递给录制MediaMuxer,注意ParcelFileDescriptor可能在录制开始之前被close回收导致录制失败,所以不要先获取内部的ParcelFileDescriptor.getFileDescriptor(),先做参数传递下去,最后在创建MediaMuxer是获取FileDescriptor

SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US);
child = format.format(new Date()) + "-" + mVideo.width + "x" + mVideo.height + ".mp4";
ParcelFileDescriptor parcelFileDescriptor = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    boolean contains = dir.getAbsolutePath().contains(getPackageName());
    if (!contains) { //primary storage,通过是否带packageName判断保存路径是不是外置SD卡,不是则是系统外置主存储器。
        Uri contentUri = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
        ContentValues values = new ContentValues();
        values.put(MediaStore.Video.VideoColumns.RELATIVE_PATH, Environment.DIRECTORY_MOVIES + File.separator + FileManager.APP_ROOT_PATH);//指定相对路径
        values.put(MediaStore.Video.VideoColumns.DISPLAY_NAME, child);
        values.put(MediaStore.Images.Media.MIME_TYPE, "video/mp4");
        values.put(MediaStore.Images.Media.IS_PENDING, 1);//独占编辑标记
        mInsert = getContentResolver().insert(contentUri, values);//获得保存文件URI
        try {
            parcelFileDescriptor = getContentResolver()
                .openFileDescriptor(mInsert, "rw");
            if (parcelFileDescriptor==null)  {
                cancelRecorder();
                MyLog.e("parcelFileDescriptor is null");
                return;
            }
        } catch (Throwable e) {
            cancelRecorder();
            e.printStackTrace();
            MyLog.e(e);
            return;
        }
    } else {
        //外置SD卡,应用自有目标下,不需要权限
        mFile = new File(dir, child);
    }
} else {
     //Android 10 以下,不需要权限
    mFile = new File(dir, child);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    FileDescriptor fileDescriptor = videoRecorderFilePathFD.getFileDescriptor();
    MyLog.e("MediaMuxer fd = " + fileDescriptor.valid());
    mMediaMuxer = new MediaMuxer(fileDescriptor,MediaMuxer.OutputFormat.
                                 MUXER_OUTPUT_MPEG_4);//Android 10 or higher 创建录制文件方式
} else {
    mMediaMuxer = new MediaMuxer(videoRecorderFilePath, MediaMuxer.OutputFormat
                                 .MUXER_OUTPUT_MPEG_4);//Android 10 or lower 创建录制文件方式
}

从 Android 10 开始,将弃用 android.preference

开发者应该改为使用 AndroidX preference 库,这是 Android Jetpack 的一部分。如需获取其他有助于迁移和开发的资源,请查看经过更新的设置指南以及我们的公开示例应用参考文档

解决方案:

修改preference类库

非 SDK 接口 限制

StickyGridHeadersGridView类使用了反射机制调用了AndroidSDK里非公开的代码,导致崩溃:

反射调用地方

void attachHeader(View header) {
    if (header == null) {
        return;
    }

    try {
        Field attachInfoField = View.class.getDeclaredField("mAttachInfo");
        attachInfoField.setAccessible(true);
        Method method = View.class.getDeclaredMethod("dispatchAttachedToWindow",
                Class.forName("android.view.View$AttachInfo"), Integer.TYPE);
        method.setAccessible(true);
        method.invoke(header, attachInfoField.get(this), View.GONE);
    } catch (NoSuchMethodException e) {
        throw new RuntimePlatformSupportException(e);
    } catch (ClassNotFoundException e) {
        throw new RuntimePlatformSupportException(e);
    } catch (IllegalArgumentException e) {
        throw new RuntimePlatformSupportException(e);
    } catch (IllegalAccessException e) {
        throw new RuntimePlatformSupportException(e);
    } catch (InvocationTargetException e) {
        throw new RuntimePlatformSupportException(e);
    } catch (NoSuchFieldException e) {
        throw new RuntimePlatformSupportException(e);
    }
}

奔溃日志:

Process: screenrecorder.recorder.editor, PID: 6623
    com.tonicartos.widget.stickygridheaders.StickyGridHeadersGridView$RuntimePlatformSupportException: Error supporting platform 29.
        at com.tonicartos.widget.stickygridheaders.StickyGridHeadersGridView.attachHeader(StickyGridHeadersGridView.java:1057)
        at com.tonicartos.widget.stickygridheaders.StickyGridHeadersBaseAdapterWrapper.getView(StickyGridHeadersBaseAdapterWrapper.java:185)

解决方法使用了RecycleView的方式替换,引入了新框架库:

implementation 'com.tonicartos:superslim:0.4.13'

使用其LayoutManager方式达到相同的显示效果。[项目地址链接](https://github.com/TonicArtos/SuperSLiM/wiki/Getting started with version 0.4)

加载图片预览

图片预览

在Android 10 以上无法直接使用file path创建视频预览图了,需要使用mediaStoreAPI的**getContentResolver().loadThumbnail()**去生成bitmap

Uri build;
boolean isVideoType = SystemUtility.isSupVideoFormatPont(itemData.name);
if (isVideoType ) {
    build = MediaStore.Video.Media.EXTERNAL_CONTENT_URI.buildUpon().appendPath(String.valueOf(itemData.id)).build();
} else {
    build = MediaStore.Images.Media.EXTERNAL_CONTENT_URI.buildUpon().appendPath(String.valueOf(itemData.id)).build();
}
try {
    Bitmap bitmap = context.getContentResolver().loadThumbnail(build, new Size(512, 348), new CancellationSignal());
    itemViewHolder.icon.setImageBitmap(bitmap);
} catch (IOException e) {
    itemViewHolder.icon.setImageResource(R.mipmap.ic_launcher);
    MyLog.e(e);
}

测试存储变更:选择停用分区存储

警告:明年,主要平台版本将要求所有应用都使用分区存储,无论应用的目标 SDK 级别是多少。因此,您应该提前确保您的应用能够使用分区存储。为此,请确保针对搭载 Android 10(API 级别 29)及更高版本的设备启用了该行为。

在您的应用完全兼容分区存储之前,您可以根据应用的目标 SDK 级别或 requestLegacyExternalStorage 清单属性,暂时选择停用分区存储:

  • 以 Android 9(API 级别 28)或更低版本为目标平台。
  • 如果以 Android 10 或更高版本为目标平台,请在应用的清单文件中将 requestLegacyExternalStorage 的值设为 true
<manifest ... >
      <!-- This attribute is "false" by default on apps targeting
           Android 10 or higher. -->
      <application android:requestLegacyExternalStorage="true" ... >
        ...
      </application>
    </manifest>

要测试以 Android 9 或更低版本为目标平台的应用在使用分区存储时的行为,您可以通过将 requestLegacyExternalStorage 的值设为 false 来选择启用该行为。

Android 10 录制视频前台服务权限需要改动

Android 10在录制视频的前台服务里必须要调用startForeground(int id, @NonNull Notification notification, @ForegroundServiceType int foregroundServiceType)

即:service中开启通知时增加ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION参数

/**
 * 没有开始录制则会开启foreground通知栏
 */
private void startForegroundServiceAndNotification() {
    if (!Prefs.getIsRecordStart(getApplicationContext())) {
        StartRecordNotifications notifications = new StartRecordNotifications(getApplicationContext());
        if (Build.MANUFACTURER.equals("OPPO") || Build.BRAND.equalsIgnoreCase("Xiaomi")) {
            //判断是否是oppo手机判断,是否是小米手机
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                startForeground(StartRecordNotifications.NOTIFICATION_ID, notifications.getXiaoMiNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
            } else {
                startForeground(StartRecordNotifications.NOTIFICATION_ID, notifications.getXiaoMiNotification());
            }
        } else {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                startForeground(StartRecordNotifications.NOTIFICATION_ID, notifications.getNormalNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
            } else {
                startForeground(StartRecordNotifications.NOTIFICATION_ID, notifications.getNormalNotification());
            }
        }
    }
}

或者在服务里头添加 android:foregroundServiceType="mediaProjection"标记, 否则未添加该参数针对target29会崩溃。

<service
    android:name="com.xvideostudio.videoeditor.windowmanager.StartRecorderService"
    android:enabled="true"
    android:foregroundServiceType="mediaProjection"
    android:exported="false" />

获取MediaProjection方式也要改动:不能再Activity里获取了,需要在有android:foregroundServiceType="mediaProjection"标记的服务里头获取mediaProjection对象

MediaProjectionManager mediaProjectionManager = (MediaProjectionManager) getApplicationContext().getSystemService(MEDIA_PROJECTION_SERVICE);
sMediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data);

截屏涂鸦功能也类似的需要在xml的FloatService 添加android:foregroundServiceType="mediaProjection"标记,并且要有通知栏标记 startForeground(int id, @NonNull Notification notification, @ForegroundServiceType int foregroundServiceType)