diff --git a/powerbell/build.properties b/powerbell/build.properties index b707cf25..2fa9194d 100644 --- a/powerbell/build.properties +++ b/powerbell/build.properties @@ -1,8 +1,8 @@ #Created by .winboll/winboll_app_build.gradle -#Mon Dec 01 00:57:13 GMT 2025 +#Mon Dec 01 01:55:32 GMT 2025 stageCount=13 libraryProject= baseVersion=15.11 publishVersion=15.11.12 -buildCount=41 +buildCount=46 baseBetaVersion=15.11.13 diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/BackgroundSettingsActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/BackgroundSettingsActivity.java index 7471677e..53520ece 100644 --- a/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/BackgroundSettingsActivity.java +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/BackgroundSettingsActivity.java @@ -154,7 +154,7 @@ public class BackgroundSettingsActivity extends WinBoLLActivity implements Backg LogUtils.e(TAG, "【初始化】bvPreviewBackground 为空,预览加载失败"); } } - + /** * 处理分享图片意图(仅UI逻辑,文件处理调用工具类) */ @@ -282,37 +282,71 @@ public class BackgroundSettingsActivity extends WinBoLLActivity implements Backg }; // 点击事件:固定比例裁剪(调用工具类获取裁剪路径,无文件逻辑) - private View.OnClickListener onCropPictureClickListener = new View.OnClickListener() { - @Override - public void onClick(View v) { - LogUtils.d(TAG, "【按钮点击】触发固定比例裁剪功能"); - // 从工具类获取正式背景文件,校验有效性 - File targetFile = new File(mBgSourceUtils.getCurrentBackgroundFilePath()); - if (targetFile.exists()) { - startCropImageActivity(false); - LogUtils.d(TAG, "【裁剪启动】固定比例裁剪已启动"); - } else { - ToastUtils.show("无可用裁剪图片,请先选择/拍照"); - LogUtils.e(TAG, "【裁剪失败】无可用裁剪图片"); - } - } - }; + // 点击事件:固定比例裁剪(添加MIUI裁剪提示) + private View.OnClickListener onCropPictureClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "【按钮点击】触发固定比例裁剪功能"); + // 从工具类获取正式背景文件,校验有效性 + File targetFile = new File(mBgSourceUtils.getCurrentBackgroundFilePath()); + if (targetFile.exists()) { + // 适配MIUI:弹出裁剪提示(建议选择系统相机) + if (Build.MANUFACTURER.equalsIgnoreCase("Xiaomi")) { + new AlertDialog.Builder(BackgroundSettingsActivity.this) + .setTitle("裁剪提示") + .setMessage("若裁剪失败,请选择「系统相机」作为裁剪工具") + .setPositiveButton("确定", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + startCropImageActivity(false); // 启动固定比例裁剪 + } + }) + .setNegativeButton("取消", null) + .show(); + } else { + // 非MIUI机型直接启动裁剪 + startCropImageActivity(false); + } + LogUtils.d(TAG, "【裁剪启动】固定比例裁剪已启动"); + } else { + ToastUtils.show("无可用裁剪图片,请先选择/拍照"); + LogUtils.e(TAG, "【裁剪失败】无可用裁剪图片"); + } + } + }; // 点击事件:自由裁剪(调用工具类获取裁剪路径,无文件逻辑) - private View.OnClickListener onCropFreePictureClickListener = new View.OnClickListener() { - @Override - public void onClick(View v) { - LogUtils.d(TAG, "【按钮点击】触发自由裁剪功能"); - File targetFile = new File(mBgSourceUtils.getCurrentBackgroundFilePath()); - if (targetFile.exists()) { - startCropImageActivity(true); - LogUtils.d(TAG, "【裁剪启动】自由裁剪已启动"); - } else { - ToastUtils.show("无可用裁剪图片,请先选择/拍照"); - LogUtils.e(TAG, "【裁剪失败】无可用裁剪图片"); - } - } - }; + // 点击事件:自由裁剪(添加MIUI裁剪提示) + private View.OnClickListener onCropFreePictureClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "【按钮点击】触发自由裁剪功能"); + File targetFile = new File(mBgSourceUtils.getCurrentBackgroundFilePath()); + if (targetFile.exists()) { + // 适配MIUI:弹出裁剪提示(建议选择系统相机) + if (Build.MANUFACTURER.equalsIgnoreCase("Xiaomi")) { + new AlertDialog.Builder(BackgroundSettingsActivity.this) + .setTitle("裁剪提示") + .setMessage("若裁剪失败,请选择「系统相机」作为裁剪工具") + .setPositiveButton("确定", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + startCropImageActivity(true); // 启动自由裁剪 + } + }) + .setNegativeButton("取消", null) + .show(); + } else { + // 非MIUI机型直接启动裁剪 + startCropImageActivity(true); + } + LogUtils.d(TAG, "【裁剪启动】自由裁剪已启动"); + } else { + ToastUtils.show("无可用裁剪图片,请先选择/拍照"); + LogUtils.e(TAG, "【裁剪失败】无可用裁剪图片"); + } + } + }; // 点击事件:拍照(仅权限+相机意图,文件处理调用工具类) private View.OnClickListener onTakePhotoClickListener = new View.OnClickListener() { @@ -492,118 +526,131 @@ public class BackgroundSettingsActivity extends WinBoLLActivity implements Backg * @param isCropFree 是否自由裁剪 */ public void startCropImageActivity(boolean isCropFree) { - LogUtils.d(TAG, "【裁剪启动】startCropImageActivity 触发,自由裁剪:" + isCropFree); - BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); - previewBean.setIsUseScaledCompress(true); - mBgSourceUtils.saveSettings(); + LogUtils.d(TAG, "【裁剪启动】startCropImageActivity 触发,自由裁剪:" + isCropFree); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + previewBean.setIsUseScaledCompress(true); + mBgSourceUtils.saveSettings(); - // 1. 校验预览图片有效性(从工具类获取路径) - File previewFile = new File(mBgSourceUtils.getPreviewBackgroundFilePath()); - LogUtils.d(TAG, "【裁剪校验】预览图片状态:路径=" + previewFile.getAbsolutePath() + ",是否存在=" + previewFile.exists()); - if (!previewFile.exists() || previewFile.length() <= 0) { - ToastUtils.show("预览图片不存在或损坏"); - LogUtils.e(TAG, "【裁剪失败】预览图片无效"); - return; - } + // 1. 预览图片有效性校验(保留强化校验逻辑) + String previewFilePath = mBgSourceUtils.getPreviewBackgroundFilePath(); + if (TextUtils.isEmpty(previewFilePath)) { + ToastUtils.show("预览图片路径为空"); + LogUtils.e(TAG, "【裁剪失败】预览图片路径为空"); + return; + } + File previewFile = new File(previewFilePath); + LogUtils.d(TAG, "【裁剪校验】预览图片状态:路径=" + previewFile.getAbsolutePath() + ",是否存在=" + previewFile.exists() + ",是否为文件=" + previewFile.isFile() + ",大小=" + (previewFile.exists() ? previewFile.length() : 0) + "bytes"); + if (!previewFile.exists() || !previewFile.isFile() || previewFile.length() <= 100) { + ToastUtils.show("预览图片不存在或损坏"); + LogUtils.e(TAG, "【裁剪失败】预览图片无效"); + return; + } - // 2. 生成裁剪输入Uri(调用工具类的FileProvider Authority) - Uri inputUri = null; - try { - inputUri = FileProvider.getUriForFile(this, mBgSourceUtils.getFileProviderAuthority(), previewFile); - LogUtils.d(TAG, "【裁剪Uri】输入Uri生成成功 : " + inputUri.toString()); - } catch (Exception e) { - LogUtils.e(TAG, "【裁剪异常】生成输入Uri失败:" + e.getMessage(), e); - ToastUtils.show("图片裁剪失败:无法获取图片权限"); - mBgSourceUtils.clearCropTempFiles(); // 调用工具类清理临时文件 - return; - } + // 2. 生成裁剪输入Uri(强化MIUI权限授予) + Uri inputUri = null; + try { + inputUri = FileProvider.getUriForFile(this, mBgSourceUtils.getFileProviderAuthority(), previewFile); + // 显式授予MIUI裁剪工具读写权限(关键适配) + grantUriPermission("com.miui.gallery", inputUri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + LogUtils.d(TAG, "【裁剪Uri】输入Uri生成成功 : " + inputUri.toString() + ",已授予MIUI裁剪工具权限"); + } catch (Exception e) { + LogUtils.e(TAG, "【裁剪异常】生成输入Uri失败:" + e.getMessage(), e); + ToastUtils.show("图片裁剪失败:无法获取图片权限"); + mBgSourceUtils.clearCropTempFiles(); + return; + } - // 3. 调用工具类创建裁剪路径(系统裁剪可读写) - File cropTempFile = mBgSourceUtils.createCropFileProviderPath(); - if (cropTempFile == null) { - ToastUtils.show("裁剪路径创建失败,请重试"); - return; - } + // 3. 调用工具类创建裁剪路径(原有逻辑不变) + File cropTempFile = mBgSourceUtils.createCropFileProviderPath(); + if (cropTempFile == null) { + ToastUtils.show("裁剪路径创建失败,请重试"); + return; + } - // 4. 构建裁剪意图(精简参数,保留核心配置) - Intent intent = new Intent("com.android.camera.action.CROP"); - intent.setDataAndType(inputUri, "image/*"); - intent.putExtra("crop", "true"); - intent.putExtra("noFaceDetection", true); + // 4. 构建裁剪意图(适配MIUI核心修改) + Intent intent = new Intent("com.android.camera.action.CROP"); + // 恢复inputUri和类型,移除setDataAndType(null, null)(MIUI必需) + intent.setDataAndType(inputUri, "image/*"); + intent.putExtra("crop", "true"); + intent.putExtra("noFaceDetection", true); - // 设置裁剪比例 - if (!isCropFree) { - int viewWidth = bvPreviewBackground.getWidth() > 0 ? bvPreviewBackground.getWidth() : getResources().getDisplayMetrics().widthPixels; - int viewHeight = bvPreviewBackground.getHeight() > 0 ? bvPreviewBackground.getHeight() : getResources().getDisplayMetrics().heightPixels; - int gcd = calculateGCD(viewWidth, viewHeight); - intent.putExtra("aspectX", viewWidth / gcd); - intent.putExtra("aspectY", viewHeight / gcd); - } else { - intent.putExtra("aspectX", 1); - intent.putExtra("aspectY", 1); - } + // 裁剪比例设置(原有逻辑不变) + if (!isCropFree) { + int viewWidth = bvPreviewBackground.getWidth() > 0 ? bvPreviewBackground.getWidth() : getResources().getDisplayMetrics().widthPixels; + int viewHeight = bvPreviewBackground.getHeight() > 0 ? bvPreviewBackground.getHeight() : getResources().getDisplayMetrics().heightPixels; + int gcd = calculateGCD(viewWidth, viewHeight); + intent.putExtra("aspectX", viewWidth / gcd); + intent.putExtra("aspectY", viewHeight / gcd); + } else { + intent.putExtra("aspectX", 1); + intent.putExtra("aspectY", 1); + } - // 设置输出尺寸(适配Android14+) - int maxOutputSize = Build.VERSION.SDK_INT >= Build_VERSION_CODES_TIRAMISU ? 1536 : 2048; - int outputX = Math.min(getResources().getDisplayMetrics().widthPixels, maxOutputSize); - int outputY = Math.min(getResources().getDisplayMetrics().heightPixels, maxOutputSize); - intent.putExtra("outputX", outputX); - intent.putExtra("outputY", outputY); - intent.putExtra("scale", true); - intent.putExtra("scaleUpIfNeeded", true); + // 输出尺寸设置(原有逻辑不变) + int maxOutputSize = Build.VERSION.SDK_INT >= Build_VERSION_CODES_TIRAMISU ? 1536 : 2048; + int outputX = Math.min(getResources().getDisplayMetrics().widthPixels, maxOutputSize); + int outputY = Math.min(getResources().getDisplayMetrics().heightPixels, maxOutputSize); + intent.putExtra("outputX", outputX); + intent.putExtra("outputY", outputY); + intent.putExtra("scale", true); + intent.putExtra("scaleUpIfNeeded", true); - // 输出配置 - intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString()); - intent.putExtra("quality", 80); - intent.putExtra("return-data", false); // 禁用返回Bitmap,避免OOM + // 输出配置(指定JPEG格式,适配MIUI) + intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString()); + intent.putExtra("quality", 80); + intent.putExtra("return-data", false); // 禁用返回Bitmap,避免OOM - // 权限Flags - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - if (Build.VERSION.SDK_INT >= Build_VERSION_CODES_TIRAMISU) { - intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - } + // 权限Flags(添加持久化权限,适配MIUI) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (Build.VERSION.SDK_INT >= Build_VERSION_CODES_TIRAMISU) { + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + } - // 5. 适配系统裁剪工具(显式指定Component) - try { - List resolveInfos = getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - if (!resolveInfos.isEmpty()) { - ResolveInfo resolveInfo = resolveInfos.get(0); - String cropPackageName = resolveInfo.activityInfo.packageName; - String cropActivityName = resolveInfo.activityInfo.name; - LogUtils.d(TAG, "【裁剪适配】找到裁剪工具:包名=" + cropPackageName + ",Activity=" + cropActivityName); + // 5. 适配系统裁剪工具(MIUI专属处理) + try { + List resolveInfos = getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + if (!resolveInfos.isEmpty()) { + ResolveInfo resolveInfo = resolveInfos.get(0); + String cropPackageName = resolveInfo.activityInfo.packageName; + String cropActivityName = resolveInfo.activityInfo.name; + LogUtils.d(TAG, "【裁剪适配】找到裁剪工具:包名=" + cropPackageName + ",Activity=" + cropActivityName); - // 生成输出Uri(授予裁剪工具权限) - Uri outputUri = FileProvider.getUriForFile(this, mBgSourceUtils.getFileProviderAuthority(), cropTempFile); - // 显式设置Component,避免参数冲突 - Intent cropIntent = new Intent(intent); - cropIntent.setComponent(new ComponentName(cropPackageName, cropActivityName)); - cropIntent.setDataAndType(null, null); - cropIntent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri); - // 重新添加权限 - cropIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + // 生成输出Uri(显式授予MIUI写入权限) + Uri outputUri = FileProvider.getUriForFile(this, mBgSourceUtils.getFileProviderAuthority(), cropTempFile); + // 适配MIUI裁剪工具,额外授予持久化写入权限 + if (cropPackageName.equals("com.miui.gallery")) { + grantUriPermission(cropPackageName, outputUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + LogUtils.d(TAG, "【裁剪适配】已授予MIUI裁剪工具输出Uri写入权限"); + } - startActivityForResult(cropIntent, REQUEST_CROP_IMAGE); - LogUtils.d(TAG, "【裁剪启动】已启动系统裁剪工具,输出路径:" + cropTempFile.getAbsolutePath()); - } else { - // 兜底:启动第三方裁剪工具 - Uri outputUri = FileProvider.getUriForFile(this, mBgSourceUtils.getFileProviderAuthority(), cropTempFile); - intent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri); - Intent chooser = Intent.createChooser(intent, "选择裁剪工具"); - chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - if (chooser.resolveActivity(getPackageManager()) != null) { - startActivityForResult(chooser, REQUEST_CROP_IMAGE); - } else { - ToastUtils.show("无可用裁剪工具,请安装系统相机"); - mBgSourceUtils.clearCropTempFiles(); - } - } - } catch (Exception e) { - LogUtils.e(TAG, "【裁剪异常】启动裁剪工具失败:" + e.getMessage(), e); - ToastUtils.show("无法启动裁剪工具"); - mBgSourceUtils.clearCropTempFiles(); - } - } + // 显式设置Component(移除setDataAndType(null, null),避免参数冲突) + Intent cropIntent = new Intent(intent); + cropIntent.setComponent(new ComponentName(cropPackageName, cropActivityName)); + cropIntent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri); + cropIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + + startActivityForResult(cropIntent, REQUEST_CROP_IMAGE); + LogUtils.d(TAG, "【裁剪启动】已启动系统裁剪工具,输出路径:" + cropTempFile.getAbsolutePath()); + } else { + // 兜底:启动第三方裁剪工具(原有逻辑不变) + Uri outputUri = FileProvider.getUriForFile(this, mBgSourceUtils.getFileProviderAuthority(), cropTempFile); + intent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri); + Intent chooser = Intent.createChooser(intent, "选择裁剪工具"); + chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (chooser.resolveActivity(getPackageManager()) != null) { + startActivityForResult(chooser, REQUEST_CROP_IMAGE); + } else { + ToastUtils.show("无可用裁剪工具,请安装系统相机"); + mBgSourceUtils.clearCropTempFiles(); + } + } + } catch (Exception e) { + LogUtils.e(TAG, "【裁剪异常】启动裁剪工具失败:" + e.getMessage(), e); + ToastUtils.show("无法启动裁剪工具"); + mBgSourceUtils.clearCropTempFiles(); + } + } /** * 计算最大公约数(简化裁剪比例) @@ -710,31 +757,39 @@ public class BackgroundSettingsActivity extends WinBoLLActivity implements Backg * 处理裁剪回调(调用工具类获取裁剪文件,精简文件操作) */ private void handleCropImageResult(int requestCode, int resultCode, Intent data) { - // 从工具类获取裁剪临时文件(唯一裁剪文件入口,无本地文件管理) + // 从工具类获取裁剪临时文件(唯一入口) File cropTempFile = mBgSourceUtils.getCropTempFile(); boolean isFileExist = cropTempFile != null && cropTempFile.exists(); boolean isFileReadable = isFileExist ? cropTempFile.canRead() : false; long fileSize = isFileExist ? cropTempFile.length() : 0; - boolean isFileValid = isFileExist && isFileReadable && fileSize > 100; // 大于100字节视为有效 - boolean isCropSuccess = (resultCode == RESULT_OK) || isFileValid; + // 适配MIUI:仅resultCode=RESULT_OK且文件有效时视为成功(resultCode=0视为取消) + boolean isCropSuccess = (resultCode == RESULT_OK) && isFileExist && isFileReadable && fileSize > 100; - // 打印校验日志(精简版,保留核心信息) + // 打印校验日志 LogUtils.d(TAG, "【裁剪回调】校验:resultCode=" + resultCode + ",文件存在=" + isFileExist + ",大小=" + fileSize + "bytes,是否成功=" + isCropSuccess); - // 处理MIUI 0字节文件问题 - if (isFileExist && fileSize == 0) { - LogUtils.e(TAG, "【裁剪失败】裁剪文件为空(MIUI适配问题)"); - ToastUtils.show("裁剪失败,请选择系统相机裁剪重试"); - mBgSourceUtils.clearCropTempFiles(); // 调用工具类清理无效文件 + // 处理MIUI裁剪取消(resultCode=0视为取消,而非失败) + if (resultCode == 0 && !isCropSuccess) { + LogUtils.d(TAG, "【裁剪回调】MIUI 裁剪工具已取消"); + ToastUtils.show("裁剪已取消"); + mBgSourceUtils.clearCropTempFiles(); return; } - // 裁剪成功:解析Bitmap并保存 + // 处理裁剪文件为空(真正的裁剪失败) + if (isFileExist && fileSize == 0) { + LogUtils.e(TAG, "【裁剪失败】裁剪文件为空(MIUI适配问题)"); + ToastUtils.show("裁剪失败,请尝试选择「系统相机」裁剪或更换图片"); + mBgSourceUtils.clearCropTempFiles(); + return; + } + + // 裁剪成功:解析Bitmap并保存(原有逻辑不变) if (isCropSuccess) { Bitmap cropBitmap = parseCropTempFileToBitmap(cropTempFile); if (cropBitmap != null && !cropBitmap.isRecycled()) { saveCropBitmap(cropBitmap); // 保存裁剪结果 - doubleRefreshPreview(); // 双重刷新预览 + doubleRefreshPreview(); // 双重刷新预览(适配MIUI渲染延迟) LogUtils.d(TAG, "【裁剪完成】裁剪回调处理结束"); } else { ToastUtils.show("获取裁剪图片失败"); @@ -742,7 +797,7 @@ public class BackgroundSettingsActivity extends WinBoLLActivity implements Backg mBgSourceUtils.clearCropTempFiles(); } } else { - // 裁剪取消/失败:统一处理 + // 其他失败场景(统一处理) handleOperationCancelOrFail(); } } diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BackgroundSourceUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BackgroundSourceUtils.java index 08893170..cdbca54a 100644 --- a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BackgroundSourceUtils.java +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BackgroundSourceUtils.java @@ -17,22 +17,18 @@ import java.io.OutputStream; /** * @Author ZhanGSKen * @Date 2024/07/18 12:07:20 - * @Describe 背景图片工具集(全量文件管理+裁剪FileProvider路径适配,线程安全+数据流转正常) - * 核心能力: - * 1. 统一管理所有文件路径(背景图/裁剪临时文件/压缩图) - * 2. 为系统裁剪应用创建可读写的FileProvider路径(适配Android14+ MIUI) - * 3. 替代BackgroundSettingsActivity的所有文件操作逻辑 + * @Describe 背景图片工具集(调整版:图片存储→/Pictures/PowerBell,JSON→应用外置存储) */ public class BackgroundSourceUtils { public static final String TAG = "BackgroundSourceUtils"; // 裁剪相关常量(统一定义,避免硬编码) - private static final String CROP_TEMP_DIR_NAME = "CropTemp"; // 裁剪临时目录(FileProvider适配) + private static final String CROP_TEMP_DIR_NAME = "cache"; // 裁剪缓存目录(基础目录下) private static final String CROP_TEMP_FILE_NAME = "SourceCropTemp.jpg"; // 裁剪输入临时文件 private static final String CROP_RESULT_FILE_NAME = "SourceCropped.jpg"; // 裁剪输出结果文件 - private static final String CROP_FALLBACK_DIR_NAME = "CropFallback"; // 裁剪兜底目录 - private static final String CROP_INNER_DIR_NAME = "CropInner"; // 优先裁剪目录(BackgroundSource下) private static final String FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".fileprovider"; // 多包名兼容 + // 图片操作基础目录(核心调整:系统公共图片目录) + private static final String PICTURE_BASE_DIR = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + File.separator + "PowerBell"; // 1. 静态实例加volatile,禁止指令重排,保证可见性(双重校验锁单例核心) private static volatile BackgroundSourceUtils sInstance; @@ -42,16 +38,17 @@ public class BackgroundSourceUtils { private File previewBackgroundBeanFile; private BackgroundBean previewBackgroundBean; - // 2. 统一文件目录(全量文件管理,替代Activity中的目录变量) + // 2. 统一文件目录(分两类:图片目录→系统公共目录,JSON目录→应用外置存储) + // 图片操作目录(系统公共目录:/storage/emulated/0/Pictures/PowerBell/) + private File fPictureBaseDir; // 图片基础目录 + private File fPictureCacheDir; // 裁剪缓存目录(基础目录下/cache) + private File fBackgroundSourceDir; // 图片存储目录(基础目录下,存储正式/预览图) + // JSON配置目录(原应用外置存储目录,不改变) private File fUtilsDir; // 工具类根目录(/Android/data/包名/files/BackgroundSourceUtils) - private File fModelDir; // 模型文件目录(存储BackgroundBean的JSON文件) - private File fBackgroundSourceDir; // 背景图片源目录(存储正式/预览图片) - private File fCropTempDir; // 裁剪临时目录(FileProvider适配路径,系统裁剪应用可读写) - private File fCropFallbackDir; // 裁剪兜底目录(应用私有外部目录失败时使用) - private File fCropInnerDir; // 新增:优先裁剪目录(BackgroundSource下,权限更可控) - private File cropTempFile; // 裁剪临时文件(系统裁剪应用写入目标) - private File cropInnerTempFile; // 新增:优先裁剪临时文件(CropInner目录下) - private File cropResultFile; // 裁剪结果文件(裁剪后保存的最终文件) + private File fModelDir; // 模型文件目录(存储JSON配置) + // 裁剪文件(统一放入图片基础目录下的cache) + private File cropTempFile; // 裁剪临时文件(fPictureCacheDir下) + private File cropResultFile; // 裁剪结果文件(fBackgroundSourceDir下) // 3. 私有构造器(加防反射逻辑+初始化所有目录/文件) private BackgroundSourceUtils(Context context) { @@ -61,21 +58,19 @@ public class BackgroundSourceUtils { } // 上下文用Application Context,避免Activity内存泄漏 this.mContext = context.getApplicationContext(); - // 初始化所有目录(文件管理核心:统一创建+权限设置) - initAllDirs(); + // 初始化目录(分图片目录+JSON目录) + initPictureDirs(); // 初始化图片操作目录(系统公共目录) + initJsonDirs(); // 初始化JSON配置目录(应用外置存储) // 初始化所有文件(裁剪临时文件/结果文件等) initAllFiles(); - // 加载配置(正式/预览Bean) + // 加载配置(正式/预览Bean,JSON目录下) loadSettings(); } // 4. 双重校验锁单例(线程安全,高效,支持多线程并发调用,Java7语法兼容) public static BackgroundSourceUtils getInstance(Context context) { - // 第一重校验:避免每次调用都加锁(提升效率) if (sInstance == null) { - // 同步锁:保证同一时刻只有一个线程进入创建逻辑 synchronized (BackgroundSourceUtils.class) { - // 第二重校验:防止多线程并发时重复创建(核心) if (sInstance == null) { sInstance = new BackgroundSourceUtils(context); } @@ -85,113 +80,101 @@ public class BackgroundSourceUtils { } /** - * 初始化所有文件目录(修改:优先初始化CropInner目录,确保权限可控) - * 包含:工具类根目录、模型目录、背景图目录、裁剪临时目录、裁剪兜底目录、优先裁剪目录 + * 初始化图片操作目录(核心调整:系统公共图片目录 /Pictures/PowerBell/) */ - private void initAllDirs() { - // 1. 工具类根目录(应用外部存储:/Android/data/包名/files/BackgroundSourceUtils) + private void initPictureDirs() { + // 1. 图片基础目录:/storage/emulated/0/Pictures/PowerBell + fPictureBaseDir = new File(PICTURE_BASE_DIR); + // 2. 图片存储目录:基础目录下(存储正式/预览图片) + fBackgroundSourceDir = new File(fPictureBaseDir, "BackgroundSource"); + // 3. 裁剪缓存目录:基础目录下/cache(所有裁剪操作在此目录) + fPictureCacheDir = new File(fPictureBaseDir, CROP_TEMP_DIR_NAME); + + // 4. 递归创建目录(系统公共目录需强制授权,确保创建成功) + createDirWithPermission(fPictureBaseDir, "图片基础目录(/Pictures/PowerBell)"); + createDirWithPermission(fBackgroundSourceDir, "图片存储目录(基础目录下)"); + createDirWithPermission(fPictureCacheDir, "裁剪缓存目录(基础目录/cache)"); + + LogUtils.d(TAG, "【图片目录初始化】完成:基础目录=" + fPictureBaseDir.getAbsolutePath() + ",裁剪缓存目录=" + fPictureCacheDir.getAbsolutePath()); + } + + /** + * 初始化JSON配置目录(保留原逻辑:应用外置存储) + */ + private void initJsonDirs() { + // 1. 工具类根目录(应用外置存储) fUtilsDir = mContext.getExternalFilesDir(TAG); if (fUtilsDir == null) { - LogUtils.e(TAG, "【文件管理】应用外部存储不可用,切换到应用内部缓存目录"); - fUtilsDir = mContext.getCacheDir(); // 极端兜底:应用内部缓存目录 + LogUtils.e(TAG, "【JSON目录】应用外置存储不可用,切换到应用内部缓存目录"); + fUtilsDir = mContext.getCacheDir(); } + // 2. 模型文件目录(存储JSON配置) + fModelDir = new File(fUtilsDir, "ModelDir"); + createDirWithPermission(fModelDir, "JSON配置目录(应用外置存储)"); - // 2. 子目录初始化(按功能划分,新增优先裁剪目录CropInner) - fModelDir = new File(fUtilsDir, "ModelDir"); // 模型文件目录(JSON配置) - fBackgroundSourceDir = new File(fUtilsDir, "BackgroundSource"); // 背景图片目录 - fCropTempDir = new File(fUtilsDir, CROP_TEMP_DIR_NAME); // 裁剪临时目录(FileProvider适配路径) - fCropFallbackDir = new File(fUtilsDir, CROP_FALLBACK_DIR_NAME); // 裁剪兜底目录 - fCropInnerDir = new File(fBackgroundSourceDir, CROP_INNER_DIR_NAME); // 优先裁剪目录(BackgroundSource下) - - // 3. 递归创建所有目录(修改:优先创建CropInner目录,确保权限初始化) - createDirWithPermission(fModelDir, "模型文件目录"); - createDirWithPermission(fBackgroundSourceDir, "背景图片目录"); - createDirWithPermission(fCropInnerDir, "优先裁剪目录(BackgroundSource下)"); // 优先创建 - createDirWithPermission(fCropTempDir, "裁剪临时目录(FileProvider适配)"); - createDirWithPermission(fCropFallbackDir, "裁剪兜底目录"); - - // 4. 初始化Bean文件对象(存储JSON配置) + // 3. 初始化JSON文件对象 currentBackgroundBeanFile = new File(fModelDir, "currentBackgroundBean.json"); previewBackgroundBeanFile = new File(fModelDir, "previewBackgroundBean.json"); - LogUtils.d(TAG, "【文件管理】所有目录初始化完成:根目录=" + fUtilsDir.getAbsolutePath() + ",优先裁剪目录=" + fCropInnerDir.getAbsolutePath()); + LogUtils.d(TAG, "【JSON目录初始化】完成:目录=" + fModelDir.getAbsolutePath()); } /** - * 初始化所有文件(修改:新增优先裁剪文件初始化) - * 包含:优先裁剪临时文件、原裁剪临时文件、裁剪结果文件 + * 初始化所有文件(裁剪文件→图片缓存目录,结果文件→图片存储目录) */ private void initAllFiles() { - // 1. 新增:优先裁剪临时文件(BackgroundSource/CropInner下,FileProvider已配置) - cropInnerTempFile = new File(fCropInnerDir, CROP_TEMP_FILE_NAME); - // 2. 原裁剪临时文件(兼容旧逻辑) - cropTempFile = new File(fCropTempDir, CROP_TEMP_FILE_NAME); - // 3. 裁剪结果文件(裁剪后保存的最终文件,存入背景图片目录) + // 1. 裁剪临时文件(图片基础目录/cache下,系统裁剪可读写) + cropTempFile = new File(fPictureCacheDir, CROP_TEMP_FILE_NAME); + // 2. 裁剪结果文件(图片存储目录下,最终保存的裁剪图) cropResultFile = new File(fBackgroundSourceDir, CROP_RESULT_FILE_NAME); - // 4. 初始化时清理旧文件(避免文件锁定/权限残留) - clearOldFile(cropInnerTempFile, "旧优先裁剪临时文件"); // 清理优先裁剪文件 - clearOldFile(cropTempFile, "旧裁剪临时文件"); - clearOldFile(cropResultFile, "旧裁剪结果文件"); + // 3. 初始化时清理旧文件(避免文件锁定/权限残留) + clearOldFile(cropTempFile, "旧裁剪临时文件(/Pictures/PowerBell/cache)"); + clearOldFile(cropResultFile, "旧裁剪结果文件(/Pictures/PowerBell/BackgroundSource)"); - LogUtils.d(TAG, "【文件管理】所有文件初始化完成:优先裁剪临时文件=" + cropInnerTempFile.getAbsolutePath() + ",裁剪结果文件=" + cropResultFile.getAbsolutePath()); + LogUtils.d(TAG, "【文件初始化】完成:裁剪临时文件=" + cropTempFile.getAbsolutePath() + ",裁剪结果文件=" + cropResultFile.getAbsolutePath()); } /** - * 核心函数:为系统裁剪应用创建可读写的FileProvider路径(修改:优先使用CropInner目录) - * 适配:Android14+、MIUI,解决Permission denied+裁剪文件为0字节问题 - * @return 裁剪临时文件(File),系统裁剪应用可读写,路径已适配FileProvider + * 核心函数:为系统裁剪应用创建可读写的FileProvider路径(适配Android14+ MIUI) + * 裁剪文件统一放入 /Pictures/PowerBell/cache/,确保系统裁剪工具可读写 */ public File createCropFileProviderPath() { LogUtils.d(TAG, "【裁剪路径】createCropFileProviderPath 触发,创建系统裁剪可读写路径"); - // 1. 优先使用BackgroundSource下的CropInner目录(核心修改:权限可控,FileProvider已配置) - if (fCropInnerDir != null && fCropInnerDir.exists() && isDirActuallyWritable(fCropInnerDir)) { + // 优先使用图片基础目录下的cache目录(核心:系统公共目录,权限更友好) + if (fPictureCacheDir != null && fPictureCacheDir.exists() && isDirActuallyWritable(fPictureCacheDir)) { try { - // 重新初始化优先裁剪临时文件(先删后建,避免文件锁定) - clearOldFile(cropInnerTempFile, "优先裁剪临时文件(重新初始化)"); - cropInnerTempFile.createNewFile(); - // 强制设置文件权限(系统裁剪应用必需:允许所有用户读写) - setFilePermissions(cropInnerTempFile); - // 关键:将当前裁剪文件指向优先裁剪文件(上层Activity直接使用) - cropTempFile = cropInnerTempFile; - LogUtils.d(TAG, "【裁剪路径】系统裁剪可读写路径创建成功(优先裁剪目录):" + cropTempFile.getAbsolutePath()); + // 先清理旧文件,避免锁定 + clearOldFile(cropTempFile, "裁剪临时文件(重新初始化)"); + // 创建新的裁剪临时文件 + cropTempFile.createNewFile(); + // 强制设置权限(系统裁剪应用必需:允许所有用户读写) + setFilePermissions(cropTempFile); + LogUtils.d(TAG, "【裁剪路径】创建成功(/Pictures/PowerBell/cache):" + cropTempFile.getAbsolutePath()); return cropTempFile; } catch (IOException e) { - LogUtils.e(TAG, "【裁剪路径】优先裁剪目录创建文件失败:" + e.getMessage(), e); + LogUtils.e(TAG, "【裁剪路径】cache目录创建文件失败:" + e.getMessage(), e); } } else { - LogUtils.w(TAG, "【裁剪路径】优先裁剪目录不可用(不存在或无权限),切换到备用目录"); + LogUtils.w(TAG, "【裁剪路径】cache目录不可用,切换到图片存储目录兜底"); } - // 2. 备用:尝试裁剪临时目录(FileProvider适配路径) - if (isDirActuallyWritable(fCropTempDir)) { + // 兜底:使用图片存储目录(极端情况) + if (isDirActuallyWritable(fBackgroundSourceDir)) { try { - clearOldFile(cropTempFile, "裁剪临时文件(重新初始化)"); - cropTempFile.createNewFile(); - setFilePermissions(cropTempFile); - LogUtils.d(TAG, "【裁剪路径】系统裁剪可读写路径创建成功(裁剪临时目录):" + cropTempFile.getAbsolutePath()); - return cropTempFile; - } catch (IOException e) { - LogUtils.e(TAG, "【裁剪路径】裁剪临时目录创建文件失败:" + e.getMessage(), e); - } - } - - // 3. 兜底1:裁剪兜底目录 - if (isDirActuallyWritable(fCropFallbackDir)) { - try { - cropTempFile = new File(fCropFallbackDir, CROP_TEMP_FILE_NAME); + cropTempFile = new File(fBackgroundSourceDir, CROP_TEMP_FILE_NAME); clearOldFile(cropTempFile, "裁剪临时文件(兜底目录)"); cropTempFile.createNewFile(); setFilePermissions(cropTempFile); - LogUtils.w(TAG, "【裁剪路径】裁剪临时目录失败,切换到兜底目录创建成功:" + cropTempFile.getAbsolutePath()); + LogUtils.w(TAG, "【裁剪路径】切换到图片存储目录创建成功:" + cropTempFile.getAbsolutePath()); return cropTempFile; } catch (IOException e) { - LogUtils.e(TAG, "【裁剪路径】裁剪兜底目录创建文件失败:" + e.getMessage(), e); + LogUtils.e(TAG, "【裁剪路径】图片存储目录创建文件失败:" + e.getMessage(), e); } } - // 4. 终极兜底:应用内部缓存目录(提示用户权限问题) + // 终极兜底:应用内部缓存目录(提示用户权限问题) File cacheDir = mContext.getCacheDir(); if (isDirActuallyWritable(cacheDir)) { try { @@ -199,23 +182,22 @@ public class BackgroundSourceUtils { clearOldFile(cropTempFile, "裁剪临时文件(终极兜底)"); cropTempFile.createNewFile(); setFilePermissions(cropTempFile); - LogUtils.w(TAG, "【裁剪路径】应用外部目录全部失败,终极兜底到缓存目录(MIUI可能裁剪失败):" + cropTempFile.getAbsolutePath()); + LogUtils.w(TAG, "【裁剪路径】系统公共目录失败,兜底到应用缓存(MIUI可能裁剪失败):" + cropTempFile.getAbsolutePath()); ToastUtils.show("存储权限受限,建议授予「所有文件访问权限」以确保裁剪正常"); return cropTempFile; } catch (IOException e) { - LogUtils.e(TAG, "【裁剪路径】终极兜底目录创建文件失败:" + e.getMessage(), e); + LogUtils.e(TAG, "【裁剪路径】终极兜底目录创建失败:" + e.getMessage(), e); ToastUtils.show("裁剪路径创建失败,请重启应用并授予存储权限"); } } - // 极端情况:所有目录均失败(返回null,上层需处理) - LogUtils.e(TAG, "【裁剪路径】所有目录均无法创建裁剪文件,系统裁剪功能不可用"); + // 极端情况:所有目录均失败 + LogUtils.e(TAG, "【裁剪路径】所有目录均无法创建裁剪文件,裁剪功能不可用"); return null; } /** - * 加载背景图片配置数据(正式/预览Bean) - * 从JSON文件读取,若文件不存在则创建默认Bean并保存 + * 加载背景图片配置数据(JSON文件仍在应用外置存储,不改变) */ void loadSettings() { // 加载正式Bean @@ -235,52 +217,64 @@ public class BackgroundSourceUtils { } } - /** - * 获取正式背景Bean(对外提供,用于修改正式配置) - */ + // ------------------------------ 对外提供的核心方法(路径已适配新目录)------------------------------ public BackgroundBean getCurrentBackgroundBean() { return currentBackgroundBean; } - /** - * 获取预览背景Bean(对外提供,用于修改预览配置) - */ public BackgroundBean getPreviewBackgroundBean() { return previewBackgroundBean; } - /** - * 获取正式背景图片路径(拼接:背景目录+正式Bean中的文件名) - */ - public String getCurrentBackgroundFilePath() { - loadSettings(); // 加载最新配置,避免数据滞后 - File file = new File(fBackgroundSourceDir, currentBackgroundBean.getBackgroundFileName()); - LogUtils.d(TAG, "【路径管理】正式背景路径:" + file.getAbsolutePath()); - return file.getAbsolutePath(); - } + /** + * 获取正式背景图片路径(修复:移除每次 loadSettings(),避免 Bean 被覆盖;强化非空校验) + */ + public String getCurrentBackgroundFilePath() { + // 移除:loadSettings(); // 关键修复:避免每次调用都重新加载Bean,导致字段被重置为空 + String fileName = currentBackgroundBean.getBackgroundFileName(); + // 强化校验:若文件名为空,返回空路径(避免拼接目录路径) + if (TextUtils.isEmpty(fileName)) { + LogUtils.e(TAG, "【路径管理】正式背景文件名为空,返回空路径"); + return ""; + } + File file = new File(fBackgroundSourceDir, fileName); + LogUtils.d(TAG, "【路径管理】正式背景路径:" + file.getAbsolutePath()); + return file.getAbsolutePath(); + } + + /** + * 获取预览背景图片路径(修复:移除每次 loadSettings(),避免 Bean 被覆盖;强化非空校验) + */ + public String getPreviewBackgroundFilePath() { + // 移除:loadSettings(); // 关键修复:避免每次调用都重新加载Bean,导致字段被重置为空 + String fileName = previewBackgroundBean.getBackgroundFileName(); + // 强化校验:若文件名为空,返回空路径(避免拼接目录路径) + if (TextUtils.isEmpty(fileName)) { + LogUtils.e(TAG, "【路径管理】预览背景文件名为空,返回空路径"); + return ""; + } + File file = new File(fBackgroundSourceDir, fileName); + LogUtils.d(TAG, "【路径管理】预览背景路径:" + file.getAbsolutePath()); + return file.getAbsolutePath(); + } + + /** + * 获取预览背景压缩图片路径(同步修复:移除 loadSettings(),强化非空校验) + */ + public String getPreviewBackgroundScaledCompressFilePath() { + // 移除:loadSettings(); // 关键修复 + String compressFileName = previewBackgroundBean.getBackgroundScaledCompressFileName(); + if (TextUtils.isEmpty(compressFileName)) { + LogUtils.e(TAG, "【路径管理】预览压缩背景文件名为空,返回空路径"); + return ""; + } + File file = new File(fBackgroundSourceDir, compressFileName); + LogUtils.d(TAG, "【路径管理】预览压缩背景路径:" + file.getAbsolutePath()); + return file.getAbsolutePath(); + } /** - * 获取预览背景图片路径(拼接:背景目录+预览Bean中的文件名) - */ - public String getPreviewBackgroundFilePath() { - loadSettings(); // 加载最新配置,避免数据滞后 - File file = new File(fBackgroundSourceDir, previewBackgroundBean.getBackgroundFileName()); - LogUtils.d(TAG, "【路径管理】预览背景路径:" + file.getAbsolutePath()); - return file.getAbsolutePath(); - } - - /** - * 获取预览背景压缩图片路径(拼接:背景目录+预览Bean中的压缩文件名) - */ - public String getPreviewBackgroundScaledCompressFilePath() { - loadSettings(); // 加载最新配置,避免数据滞后 - File file = new File(fBackgroundSourceDir, previewBackgroundBean.getBackgroundScaledCompressFileName()); - LogUtils.d(TAG, "【路径管理】预览压缩背景路径:" + file.getAbsolutePath()); - return file.getAbsolutePath(); - } - - /** - * 保存配置(将正式/预览Bean同步到JSON文件,持久化存储) + * 保存配置(JSON文件仍写入应用外置存储) */ public void saveSettings() { BackgroundBean.saveBeanToFile(currentBackgroundBeanFile.getAbsolutePath(), currentBackgroundBean); @@ -289,39 +283,27 @@ public class BackgroundSourceUtils { } /** - * 获取背景图片源目录路径(对外提供,用于创建临时文件) + * 获取图片基础目录路径(对外提供:/Pictures/PowerBell/) */ public String getBackgroundSourceDirPath() { return fBackgroundSourceDir.getAbsolutePath(); } - /** - * 获取裁剪临时文件(对外提供,Activity中用于传递给系统裁剪应用) - */ public File getCropTempFile() { return cropTempFile; } - /** - * 获取裁剪结果文件(对外提供,Activity中用于获取裁剪后的图片) - */ public File getCropResultFile() { return cropResultFile; } - /** - * 获取FileProvider授权Authority(多包名兼容,对外提供给Activity) - */ public String getFileProviderAuthority() { return FILE_PROVIDER_AUTHORITY; } + // ------------------------------ 工具方法(适配新目录权限)------------------------------ /** - * 新增:流复制文件(核心修复:Android14+ 共享存储权限限制适配) - * 不依赖文件路径,直接通过流复制,支持读取相册私有隐藏文件,避免Permission denied - * @param source 源文件(可为共享存储私有文件) - * @param target 目标文件(应用私有目录,确保可写) - * @return true=复制成功,false=失败 + * 流复制文件(适配系统公共目录,解决Android14+ 权限问题) */ public boolean copyFileByStream(File source, File target) { if (source == null || !source.exists() || !source.isFile() || target == null) { @@ -329,40 +311,46 @@ public class BackgroundSourceUtils { return false; } - // 确保目标目录存在 + // 确保目标目录存在(系统公共目录需强制创建,适配/Pictures/PowerBell/路径) File targetDir = target.getParentFile(); if (!targetDir.exists()) { - createDirWithPermission(targetDir, "流复制目标目录"); + createDirWithPermission(targetDir, "流复制目标目录(/Pictures/PowerBell下)"); } FileInputStream fis = null; FileOutputStream fos = null; try { - // 打开源文件输入流(支持共享存储私有文件) + // 打开源文件输入流(支持共享存储私有文件/系统公共目录文件) fis = new FileInputStream(source); - // 打开目标文件输出流 + // 打开目标文件输出流(适配/Pictures/PowerBell目录权限) fos = new FileOutputStream(target); byte[] buffer = new byte[1024 * 8]; // 8KB缓冲区,提升复制效率 int len; - // 循环读取流并写入目标文件(Java7 普通for循环,兼容语法) + // 循环读取流并写入目标文件(Java7 兼容语法) while ((len = fis.read(buffer)) != -1) { fos.write(buffer, 0, len); } fos.flush(); - fos.getFD().sync(); // 强制同步到磁盘,确保文件写入完成(Java7 支持) + fos.getFD().sync(); // 强制同步到磁盘,避免系统公共目录缓存导致文件损坏 LogUtils.d(TAG, "【文件管理】流复制成功:" + source.getAbsolutePath() + " → " + target.getAbsolutePath() + ",大小:" + target.length() + "bytes"); + // 复制成功后强制设置目标文件权限(确保后续裁剪/预览可读写) + setFilePermissions(target); return true; } catch (Exception e) { - LogUtils.e(TAG, "【文件管理】流复制异常:" + e.getMessage(), e); - // 复制失败时删除目标文件(避免残留空文件) + LogUtils.e(TAG, "【文件管理】流复制异常(/Pictures/PowerBell目录):" + e.getMessage(), e); + // 复制失败时删除目标文件(避免残留空文件导致后续逻辑异常) if (target.exists()) { clearOldFileByExternal(target, "流复制失败残留文件"); } + // 针对系统公共目录权限异常,给出明确提示 + if (e instanceof SecurityException || e.getMessage().contains("EACCES")) { + ToastUtils.show("图片复制失败,请授予应用「存储权限」和「所有文件访问权限」"); + } return false; } finally { - // 关闭流资源(Java7 手动关闭,避免内存泄漏,不使用try-with-resources) + // 关闭流资源(Java7 手动关闭,避免内存泄漏,不依赖try-with-resources) if (fis != null) { try { fis.close(); @@ -381,29 +369,29 @@ public class BackgroundSourceUtils { } /** - * 保存图片到预览Bean(核心修复:替换路径复制为流复制,避免预览路径错误) + * 保存图片到预览Bean(图片存储到/Pictures/PowerBell/BackgroundSource,JSON仍存应用外置存储) * @param sourceFile 源图片文件(非空,必须存在) * @param fileInfo 图片附加信息(如Uri字符串,仅作备注) * @return 更新后的预览Bean */ public BackgroundBean saveFileToPreviewBean(File sourceFile, String fileInfo) { - // 强化校验:源文件必须存在、是文件、大小>0 + // 强化校验:源文件必须存在、是文件、大小>0(避免无效文件复制) if (sourceFile == null || !sourceFile.exists() || !sourceFile.isFile() || sourceFile.length() <= 0) { LogUtils.e(TAG, "【文件管理】源文件无效:" + (sourceFile != null ? sourceFile.getAbsolutePath() : "null") + ",大小:" + (sourceFile != null ? sourceFile.length() : 0) + "bytes"); ToastUtils.show("源图片文件无效"); return previewBackgroundBean; } - // 确保背景目录存在(防止首次使用时目录未创建) + // 确保图片存储目录存在(/Pictures/PowerBell/BackgroundSource) if (!fBackgroundSourceDir.exists()) { - createDirWithPermission(fBackgroundSourceDir, "背景图片目录(saveFileToPreviewBean)"); + createDirWithPermission(fBackgroundSourceDir, "图片存储目录(saveFileToPreviewBean)"); } - // 生成唯一文件名(基于源文件后缀,避免重复) + // 生成唯一文件名(基于源文件后缀,避免重复,适配系统公共目录) String uniqueFileName = FileUtils.createUniqueFileName(sourceFile); File previewBackgroundFile = new File(fBackgroundSourceDir, uniqueFileName); - // 核心修改:用流复制替代原FileUtils.copyFile,解决共享存储权限问题 + // 核心:用流复制替代原FileUtils.copyFile,适配/Pictures/PowerBell目录权限 boolean copySuccess = copyFileByStream(sourceFile, previewBackgroundFile); if (!copySuccess) { LogUtils.e(TAG, "【文件管理】图片复制到预览目录失败:" + sourceFile.getAbsolutePath() + " → " + previewBackgroundFile.getAbsolutePath()); @@ -411,20 +399,23 @@ public class BackgroundSourceUtils { return previewBackgroundBean; } - // 正确赋值预览Bean(确保文件名非空,避免后续路径为空) - previewBackgroundBean = new BackgroundBean(); - previewBackgroundBean.setBackgroundFileName(previewBackgroundFile.getName()); // 唯一文件名(非空) - previewBackgroundBean.setBackgroundScaledCompressFileName("ScaledCompress_" + previewBackgroundFile.getName()); // 压缩文件名(前缀标识) - previewBackgroundBean.setBackgroundFileInfo(fileInfo); // 附加信息(Uri) - previewBackgroundBean.setIsUseBackgroundFile(true); // 标记使用背景图 - previewBackgroundBean.setIsUseScaledCompress(true); // 启用压缩图 - previewBackgroundBean.setBackgroundWidth(100); // 默认宽高比1:1 - previewBackgroundBean.setBackgroundHeight(100); - saveSettings(); // 持久化保存预览Bean + // 正确赋值预览Bean(确保文件名非空) + previewBackgroundBean = new BackgroundBean(); + previewBackgroundBean.setBackgroundFileName(previewBackgroundFile.getName()); // 唯一文件名(非空) + previewBackgroundBean.setBackgroundScaledCompressFileName("ScaledCompress_" + previewBackgroundFile.getName()); + previewBackgroundBean.setBackgroundFileInfo(fileInfo); + previewBackgroundBean.setIsUseBackgroundFile(true); + previewBackgroundBean.setIsUseScaledCompress(true); + previewBackgroundBean.setBackgroundWidth(100); + previewBackgroundBean.setBackgroundHeight(100); - LogUtils.d(TAG, "【文件管理】预览图片保存成功:" + previewBackgroundFile.getAbsolutePath() + ",大小:" + previewBackgroundFile.length() + "bytes"); - ToastUtils.show("预览图片加载成功"); - return previewBackgroundBean; + // 关键强化:强制保存Bean到JSON,确保后续loadSettings()能加载到有效Bean + BackgroundBean.saveBeanToFile(previewBackgroundBeanFile.getAbsolutePath(), previewBackgroundBean); + LogUtils.d(TAG, "【文件管理】预览Bean强制保存到JSON:" + previewBackgroundBeanFile.getAbsolutePath()); + + LogUtils.d(TAG, "【文件管理】预览图片保存成功(/Pictures/PowerBell):" + previewBackgroundFile.getAbsolutePath() + ",大小:" + previewBackgroundFile.length() + "bytes"); + ToastUtils.show("预览图片加载成功"); + return previewBackgroundBean; } /** @@ -442,8 +433,8 @@ public class BackgroundSourceUtils { currentBackgroundBean.setBackgroundHeight(previewBackgroundBean.getBackgroundHeight()); currentBackgroundBean.setPixelColor(previewBackgroundBean.getPixelColor()); - saveSettings(); // 持久化保存正式Bean - LogUtils.d(TAG, "【配置管理】预览背景提交成功:正式背景更新为预览背景"); + saveSettings(); // 持久化保存正式Bean(JSON写入应用外置存储) + LogUtils.d(TAG, "【配置管理】预览背景提交成功:正式背景更新为/Pictures/PowerBell下的预览背景"); ToastUtils.show("背景图片应用成功"); } @@ -462,12 +453,12 @@ public class BackgroundSourceUtils { previewBackgroundBean.setBackgroundHeight(currentBackgroundBean.getBackgroundHeight()); previewBackgroundBean.setPixelColor(currentBackgroundBean.getPixelColor()); - saveSettings(); // 持久化保存预览Bean - LogUtils.d(TAG, "【配置管理】正式背景同步到预览:预览背景更新为当前正式背景"); + saveSettings(); // 持久化保存预览Bean(JSON写入应用外置存储) + LogUtils.d(TAG, "【配置管理】正式背景同步到预览:预览背景更新为/Pictures/PowerBell下的正式背景"); } /** - * 工具方法:创建目录并设置权限(确保目录可读写,适配Android14+) + * 工具方法:创建目录并设置权限(适配系统公共目录/Pictures/PowerBell,确保可读写) * @param dir 要创建的目录 * @param dirDesc 目录描述(用于日志打印) */ @@ -488,12 +479,12 @@ public class BackgroundSourceUtils { } } } - // 强制设置目录权限(递归设置,确保所有层级可读写) + // 强制设置目录权限(递归设置,确保系统公共目录所有层级可读写) setDirPermissionsRecursively(dir); } /** - * 工具方法:递归设置目录及子目录/文件的读写权限(适配Android全版本) + * 工具方法:递归设置目录及子目录/文件的读写权限(适配系统公共目录/Pictures/PowerBell) * @param dir 要设置权限的目录 */ private void setDirPermissionsRecursively(File dir) { @@ -503,7 +494,7 @@ public class BackgroundSourceUtils { return; } try { - // 设置目录权限(允许所有用户读写,系统裁剪应用必需) + // 设置目录权限(允许所有用户读写,系统裁剪应用/预览功能必需) dir.setReadable(true, false); dir.setWritable(true, false); dir.setExecutable(false, false); @@ -518,27 +509,28 @@ public class BackgroundSourceUtils { if (file.isDirectory()) { setDirPermissionsRecursively(file); } else { - // 设置文件权限(与目录一致) + // 设置文件权限(与目录一致,确保可读写) file.setReadable(true, false); file.setWritable(true, false); file.setExecutable(false, false); - // 裁剪相关文件单独打印日志 - if (file.getName().contains(CROP_TEMP_FILE_NAME) || file.getName().contains(CROP_RESULT_FILE_NAME)) { - LogUtils.d(TAG, "【权限管理】裁剪文件权限设置:文件名=" + file.getName() + ",可写=" + file.canWrite()); + // 裁剪/预览相关文件单独打印日志 + if (file.getName().contains(CROP_TEMP_FILE_NAME) || file.getName().contains(CROP_RESULT_FILE_NAME) || file.getName().startsWith("ScaledCompress_")) { + LogUtils.d(TAG, "【权限管理】关键文件权限设置:文件名=" + file.getName() + ",可写=" + file.canWrite()); } } } } } catch (SecurityException e) { LogUtils.e(TAG, "【权限管理】设置目录权限失败(系统禁止):" + dir.getAbsolutePath() + ",错误:" + e.getMessage(), e); + ToastUtils.show("目录权限设置失败,请授予应用存储权限"); } catch (Exception e) { LogUtils.e(TAG, "【权限管理】设置目录权限异常:" + dir.getAbsolutePath() + ",错误:" + e.getMessage(), e); } } /** - * 工具方法:设置单个文件权限(确保系统裁剪应用可读写) - * 【关键调整】修改为public,适配BackgroundSettingsActivity的外部调用 + * 工具方法:设置单个文件权限(确保系统裁剪应用/预览功能可读写,适配/Pictures/PowerBell目录) + * 【关键调整】public修饰,适配BackgroundSettingsActivity的外部调用 * @param file 要设置权限的文件 */ public void setFilePermissions(File file) { @@ -551,14 +543,14 @@ public class BackgroundSourceUtils { file.setReadable(true, false); file.setWritable(true, false); file.setExecutable(false, false); - LogUtils.d(TAG, "【权限管理】文件权限设置完成:路径=" + file.getAbsolutePath() + ",可写=" + file.canWrite() + ",可读=" + file.canRead()); + LogUtils.d(TAG, "【权限管理】文件权限设置完成(/Pictures/PowerBell下):路径=" + file.getAbsolutePath() + ",可写=" + file.canWrite() + ",可读=" + file.canRead()); } catch (Exception e) { LogUtils.e(TAG, "【权限管理】设置文件权限失败:" + file.getAbsolutePath() + ",错误:" + e.getMessage(), e); } } /** - * 工具方法:清理旧文件(避免文件锁定/残留)【内部私有,不对外暴露】 + * 工具方法:清理旧文件(避免文件锁定/残留,适配系统公共目录)【内部私有,不对外暴露】 * @param file 要清理的文件 * @param fileDesc 文件描述(用于日志打印) */ @@ -567,6 +559,8 @@ public class BackgroundSourceUtils { return; } if (file.exists()) { + // 先设置文件为可写(避免系统公共目录下文件只读导致删除失败) + file.setWritable(true, false); boolean deleteSuccess = file.delete(); LogUtils.d(TAG, "【文件管理】清理" + fileDesc + ":" + (deleteSuccess ? "成功" : "失败") + ",路径:" + file.getAbsolutePath()); // 若删除失败,标记为退出时删除(兼容文件锁定场景) @@ -578,7 +572,7 @@ public class BackgroundSourceUtils { } /** - * 工具方法:验证目录实际写入能力(解决Android14+ canWrite()假阳性问题) + * 工具方法:验证目录实际写入能力(解决Android14+ canWrite()假阳性问题,适配/Pictures/PowerBell) * 原理:通过创建临时空文件并删除,验证目录是否真的可写 * @param dir 要验证的目录 * @return true=实际可写,false=实际不可写 @@ -595,7 +589,7 @@ public class BackgroundSourceUtils { boolean canWrite = testFile.canWrite(); boolean canRead = testFile.canRead(); testFile.delete(); // 删除临时文件,不占用空间 - LogUtils.d(TAG, "【权限校验】目录实际写入校验:" + dir.getAbsolutePath() + ",创建成功=" + createSuccess + ",可写=" + canWrite + ",可读=" + canRead + ",结果=" + (canWrite ? "通过" : "失败")); + LogUtils.d(TAG, "【权限校验】目录实际写入校验(/Pictures/PowerBell下):" + dir.getAbsolutePath() + ",创建成功=" + createSuccess + ",可写=" + canWrite + ",可读=" + canRead + ",结果=" + (canWrite ? "通过" : "失败")); return canWrite; } else { LogUtils.d(TAG, "【权限校验】目录实际写入校验失败:" + dir.getAbsolutePath() + ",创建临时文件失败(Permission denied)"); @@ -608,75 +602,65 @@ public class BackgroundSourceUtils { } /** - * 工具方法:复制文件(适配大文件,避免OOM) - * 【关键优化】兼容源文件为空的场景(适配Activity中mBgSourceUtils.copyFile(new File(""), parentDir)调用) + * 工具方法:复制文件(适配大文件,避免OOM,兼容源文件为空场景) + * 【关键优化】适配Activity中mBgSourceUtils.copyFile(new File(""), parentDir)调用 * @param source 源文件(可为空,为空时仅创建目标目录) * @param target 目标文件/目录(若源文件为空,target视为目录并创建) * @return true=复制/创建成功,false=失败 */ public boolean copyFile(File source, File target) { - // 场景1:源文件为空 → 仅创建目标目录(适配Activity的目录创建调用) + // 场景1:源文件为空 → 仅创建目标目录(适配Activity中mBgSourceUtils.copyFile(new File(""), parentDir)调用) if (source == null || (source.exists() && source.length() <= 0)) { if (target == null) { LogUtils.e(TAG, "【文件管理】目录创建失败:目标目录对象为null"); return false; } - // 若target是文件,取其父目录;若本身是目录,直接创建 + // 若target是文件,取其父目录;若本身是目录,直接创建(适配/Pictures/PowerBell目录) File targetDir = target.isFile() ? target.getParentFile() : target; - createDirWithPermission(targetDir, "空源文件场景-目录创建"); + createDirWithPermission(targetDir, "空源文件场景-目录创建(/Pictures/PowerBell下)"); LogUtils.d(TAG, "【文件管理】空源文件场景:目录创建完成,路径=" + targetDir.getAbsolutePath()); return true; } - // 场景2:正常文件复制(源文件非空且存在) + // 场景2:正常文件复制(源文件非空且存在,适配系统公共目录/Pictures/PowerBell) if (!source.exists() || target == null) { LogUtils.e(TAG, "【文件管理】文件复制失败:源文件无效或目标文件为空"); return false; } - // 确保目标目录存在 + // 确保目标目录存在(系统公共目录需强制创建,避免权限问题) File targetDir = target.getParentFile(); if (!targetDir.exists()) { - createDirWithPermission(targetDir, "文件复制目标目录"); + createDirWithPermission(targetDir, "文件复制目标目录(/Pictures/PowerBell下)"); } - // 调用FileUtils复制(若项目中已有该方法,可直接复用) - return FileUtils.copyFile(source, target); + // 调用流复制方法(适配系统公共目录权限,避免Permission denied) + return copyFileByStream(source, target); } /** - * 工具方法:清理裁剪相关临时文件(对外提供,Activity退出时调用) + * 工具方法:清理裁剪相关临时文件(对外提供,Activity退出时调用,适配/Pictures/PowerBell/cache目录) */ public void clearCropTempFiles() { - clearOldFile(cropTempFile, "裁剪临时文件"); - clearOldFile(cropResultFile, "裁剪结果文件"); - // 清理裁剪目录下的其他临时文件(Java7 普通for循环) - if (fCropTempDir.exists()) { - File[] files = fCropTempDir.listFiles(); + clearOldFile(cropTempFile, "裁剪临时文件(/Pictures/PowerBell/cache)"); + clearOldFile(cropResultFile, "裁剪结果文件(/Pictures/PowerBell/BackgroundSource)"); + // 清理裁剪缓存目录下的其他临时文件(Java7 普通for循环,兼容语法) + if (fPictureCacheDir.exists()) { + File[] files = fPictureCacheDir.listFiles(); if (files != null && files.length > 0) { for (int i = 0; i < files.length; i++) { File file = files[i]; if (file.isFile()) { + // 强制设置为可写后删除(避免系统公共目录文件只读) + file.setWritable(true, false); file.delete(); } } } } - // 新增:清理优先裁剪目录(CropInner)下的临时文件 - if (fCropInnerDir != null && fCropInnerDir.exists()) { - File[] files = fCropInnerDir.listFiles(); - if (files != null && files.length > 0) { - for (int i = 0; i < files.length; i++) { - File file = files[i]; - if (file.isFile()) { - file.delete(); - } - } - } - } - LogUtils.d(TAG, "【文件管理】裁剪相关临时文件清理完成"); + LogUtils.d(TAG, "【文件管理】裁剪相关临时文件清理完成(/Pictures/PowerBell下)"); } /** - * 对外接口:清理指定旧文件(适配BackgroundSettingsActivity调用) + * 对外接口:清理指定旧文件(适配BackgroundSettingsActivity调用,支持/Pictures/PowerBell目录) * @param file 要清理的文件 * @param fileDesc 文件描述(用于日志打印) */ @@ -685,7 +669,7 @@ public class BackgroundSourceUtils { } /** - * 工具方法:获取目录类型描述(用于日志调试,明确目录类型) + * 工具方法:获取目录类型描述(用于日志调试,明确目录类型,适配新目录结构) * @param dir 目标目录 * @return 目录类型描述 */ @@ -694,11 +678,14 @@ public class BackgroundSourceUtils { return "未知目录(null)"; } String dirPath = dir.getAbsolutePath(); + String publicPicturePath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).getAbsolutePath(); String externalFilesPath = mContext.getExternalFilesDir(null) != null ? mContext.getExternalFilesDir(null).getAbsolutePath() : ""; String cachePath = mContext.getCacheDir().getAbsolutePath(); - if (!TextUtils.isEmpty(externalFilesPath) && dirPath.contains(externalFilesPath)) { - return "应用私有外部目录(getExternalFilesDir(),系统裁剪可读写)"; + if (!TextUtils.isEmpty(publicPicturePath) && dirPath.contains(publicPicturePath + File.separator + "PowerBell")) { + return "系统公共图片目录(/Pictures/PowerBell,图片存储/裁剪目录)"; + } else if (!TextUtils.isEmpty(externalFilesPath) && dirPath.contains(externalFilesPath)) { + return "应用私有外部目录(getExternalFilesDir(),JSON配置目录)"; } else if (dirPath.contains(cachePath)) { return "应用内部缓存目录(getCacheDir(),兜底目录)"; } else { diff --git a/powerbell/src/main/res/xml/file_provider.xml b/powerbell/src/main/res/xml/file_provider.xml index 4ab353de..fb2fba7c 100644 --- a/powerbell/src/main/res/xml/file_provider.xml +++ b/powerbell/src/main/res/xml/file_provider.xml @@ -55,5 +55,12 @@ - + + + +