diff --git a/powerbell/build.properties b/powerbell/build.properties index c114eca..cef3eac 100644 --- a/powerbell/build.properties +++ b/powerbell/build.properties @@ -1,8 +1,8 @@ #Created by .winboll/winboll_app_build.gradle -#Sun Dec 14 04:55:45 HKT 2025 +#Sun Dec 14 08:52:47 GMT 2025 stageCount=3 libraryProject= baseVersion=15.14 publishVersion=15.14.2 -buildCount=0 +buildCount=42 baseBetaVersion=15.14.3 diff --git a/powerbell/src/main/AndroidManifest.xml b/powerbell/src/main/AndroidManifest.xml index 4113f48..02a6bdc 100644 --- a/powerbell/src/main/AndroidManifest.xml +++ b/powerbell/src/main/AndroidManifest.xml @@ -4,34 +4,56 @@ xmlns:tools="http://schemas.android.com/tools" package="cc.winboll.studio.powerbell"> - + + + - + - + - + - - + - - - + + + + + - - + + + + + + + + + + + + + + + + + + android:supportsRtl="true" + tools:ignore="GoogleAppIndexingWarning,UnusedAttribute"> + - + + + + + - + - - - - - - - - - - - - - - - - - - - - + android:launchMode="singleTask" + android:exported="false"/> - - - - - - - - - - + - - - + - + + + - + + + android:process=".controlcenterservice" + android:foregroundServiceType="dataSync"> + + + + + android:process=".assistantservice"> + + + - - - - - - - + + + + + + + + - - - - - - + - - \ No newline at end of file + + diff --git a/powerbell/src/main/assets/unittest/unittest.png b/powerbell/src/main/assets/unittest/unittest.png new file mode 100644 index 0000000..ac4f4dc Binary files /dev/null and b/powerbell/src/main/assets/unittest/unittest.png differ diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/MainActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/MainActivity.java index 5714eee..3522405 100644 --- a/powerbell/src/main/java/cc/winboll/studio/powerbell/MainActivity.java +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/MainActivity.java @@ -129,20 +129,7 @@ public class MainActivity extends WinBoLLActivity { @Override protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); - - // 电池优化权限(通用所有机型) - if (!permissionUtils.checkIgnoreBatteryOptimizationPermission(this)) { - YesNoAlertDialog.show(this, getString(R.string.app_name) + "权限申请提示:", "本应用要正常使用,需要申请电池优化与自启动权限。是否进入权限设置步骤?", new YesNoAlertDialog.OnDialogResultListener(){ - @Override - public void onNo() { - ToastUtils.show(getString(R.string.app_name) + "应用可能无法正常使用。"); - } - @Override - public void onYes() { - permissionUtils.requestIgnoreBatteryOptimizationPermission(MainActivity.this); - } - }); - } + permissionUtils.startPermissionRequest(this); } @Override @@ -226,13 +213,9 @@ public class MainActivity extends WinBoLLActivity { protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - if (requestCode == PermissionUtils.REQUEST_IGNORE_BATTERY_OPTIMIZATION) { - // 自启动权限(小米专属) - if (permissionUtils.checkAutoStartPermission(this)) { - // 小米机型,发起自启动权限申请 - permissionUtils.requestAutoStartPermission(this); - } - } else if (requestCode == REQUEST_READ_MEDIA_IMAGES) { + permissionUtils.handlePermissionRequest(this, requestCode, resultCode, data); + + if (requestCode == REQUEST_READ_MEDIA_IMAGES) { if (_mHandler != null) { _mHandler.sendEmptyMessage(MSG_LOAD_BACKGROUND); } diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/unittest/MainUnitTestActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/unittest/MainUnitTestActivity.java index c6f059c..1862b43 100644 --- a/powerbell/src/main/java/cc/winboll/studio/powerbell/unittest/MainUnitTestActivity.java +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/unittest/MainUnitTestActivity.java @@ -1,183 +1,249 @@ package cc.winboll.studio.powerbell.unittest; import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; import android.os.Bundle; import android.os.Environment; +import android.os.Handler; +import android.os.Looper; import android.view.View; import android.widget.Button; import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; import cc.winboll.studio.libappbase.LogUtils; import cc.winboll.studio.libappbase.ToastUtils; import cc.winboll.studio.powerbell.MainActivity; import cc.winboll.studio.powerbell.R; import cc.winboll.studio.powerbell.utils.BackgroundSourceUtils; +import cc.winboll.studio.powerbell.utils.FileUtils; import cc.winboll.studio.powerbell.utils.ImageCropUtils; import cc.winboll.studio.powerbell.views.BackgroundView; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import cc.winboll.studio.powerbell.models.BackgroundBean; /** - * @Author ZhanGSKen&豆包大模型 - * @Date 2025/11/19 18:04 - * @Describe 单元测试启动主页窗口 + * 终极修复版:放弃FileProvider,直接用私有目录File路径,彻底解决UID冲突 */ public class MainUnitTestActivity extends AppCompatActivity { - + // ====================== 常量定义 ====================== public static final String TAG = "MainUnitTestActivity"; public static final int REQUEST_CROP_IMAGE = 0; - // 新增:权限请求码 - public static final int REQUEST_STORAGE_PERMISSION = 1001; - View mainView; - BackgroundSourceUtils mBgSourceUtils; - BackgroundView mBackgroundView; - // 测试图片路径(用Environment获取,适配低版本,避免硬编码) - String szTestSource = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) - + "/PowerBell/unittest/1764946782079.jpeg"; - + private static final String ASSETS_TEST_IMAGE_PATH = "unittest/unittest.png"; + + // ====================== 成员变量(移除所有Uri相关) ====================== + private BackgroundView mBackgroundView; + private String mAppPrivateDirPath; + private File mPrivateTestImageFile; // 仅用File,不用Uri + private File mPrivateCropImageFile; + BackgroundBean mPreviewBackgroundBean; + + // ====================== 生命周期方法 ====================== @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - - mBgSourceUtils = BackgroundSourceUtils.getInstance(this); - mBgSourceUtils.loadSettings(); - - setContentView(R.layout.activity_mainunittest); - - mBackgroundView = findViewById(R.id.backgroundview); + LogUtils.d(TAG, "=== 页面 onCreate 启动 ==="); - ((Button)findViewById(R.id.btn_main_activity)).setOnClickListener(new View.OnClickListener(){ - @Override - public void onClick(View v) { - startActivity(new Intent(MainUnitTestActivity.this, MainActivity.class)); - } - }); - - // 裁剪测试按钮点击事件(新增权限校验) - ((Button)findViewById(R.id.btn_test_cropimage)).setOnClickListener(new View.OnClickListener(){ - @Override - public void onClick(View v) { - ToastUtils.show("onClick:准备启动裁剪"); - LogUtils.d(TAG, "【裁剪测试】点击裁剪按钮,校验权限"); - - // 修复1:移除高版本API依赖,适配低版本存储权限校验 - if (checkStoragePermission()) { - // 权限已授予,启动裁剪 - startCropTest(); - } else { - // 权限未授予,申请权限 - requestStoragePermission(); - } - } - }); + initBaseParams(); + initViewAndEvent(); + copyAssetsTestImageToPrivateDir(); + //loadBackgroundByFile(); // 直接用File加载 + mPreviewBackgroundBean = new BackgroundBean(); + mPreviewBackgroundBean.setBackgroundFileName(mPrivateTestImageFile.getName()); + mPreviewBackgroundBean.setBackgroundFilePath(mPrivateTestImageFile.getAbsolutePath()); + mPreviewBackgroundBean.setBackgroundScaledCompressFileName(mPrivateCropImageFile.getName()); + mPreviewBackgroundBean.setBackgroundScaledCompressFilePath(mPrivateCropImageFile.getAbsolutePath()); + mPreviewBackgroundBean.setIsUseBackgroundFile(true); + doubleRefreshPreview(); - ToastUtils.show(String.format("%s onCreate", TAG)); - // 加载测试图片(验证图片路径是否有效) - loadBackground(); + ToastUtils.show("单元测试页面启动完成"); + LogUtils.d(TAG, "=== 页面 onCreate 初始化结束 ==="); } - /** - * 启动裁剪测试(抽取为单独方法,便于权限回调后调用) - */ - private void startCropTest() { - // 修复2:输出路径用Environment获取,确保目录存在(避免路径无效) - File outputDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) - + "/PowerBell/unittest/"); - if (!outputDir.exists()) { - outputDir.mkdirs(); // 创建目录(避免输出路径不存在导致裁剪失败) - LogUtils.d(TAG, "【裁剪测试】创建输出目录:" + outputDir.getAbsolutePath()); - } - String dstOutputPath = outputDir.getAbsolutePath() - + "/1764946782079-crop.jpeg"; - - // 修复3:自由裁剪时比例传0(避免100:100过大导致机型崩溃) - ImageCropUtils.startImageCrop( - MainUnitTestActivity.this, - new File(szTestSource), - new File(dstOutputPath), - 0, // 自由裁剪传0 - 0, // 自由裁剪传0 - true, - REQUEST_CROP_IMAGE - ); - } - - /** - * 校验存储读写权限(适配Android 6.0+ 低版本SDK,移除TIRAMISU依赖) - */ - private boolean checkStoragePermission() { - // 适配Android 6.0(API 23)及以上,用通用的读写权限(移除高版本API) - return ContextCompat.checkSelfPermission(this, android.Manifest.permission.READ_EXTERNAL_STORAGE) - == PackageManager.PERMISSION_GRANTED - && ContextCompat.checkSelfPermission(this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE) - == PackageManager.PERMISSION_GRANTED; - } - - /** - * 申请存储读写权限(适配低版本SDK,移除READ_MEDIA_IMAGES依赖) - */ - private void requestStoragePermission() { - LogUtils.d(TAG, "【裁剪测试】申请存储读写权限"); - // 用通用的读写权限(适配所有Android 6.0+ 机型,无高版本依赖) - ActivityCompat.requestPermissions( - this, - new String[]{android.Manifest.permission.READ_EXTERNAL_STORAGE, android.Manifest.permission.WRITE_EXTERNAL_STORAGE}, - REQUEST_STORAGE_PERMISSION - ); - } - - /** - * 权限申请回调 - */ @Override - public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requestCode == REQUEST_STORAGE_PERMISSION) { - // 校验权限是否授予 - boolean allGranted = true; - for (int result : grantResults) { - if (result != PackageManager.PERMISSION_GRANTED) { - allGranted = false; - break; + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + LogUtils.d(TAG, "=== onActivityResult 回调 ==="); + if (requestCode == REQUEST_CROP_IMAGE) { + handleCropResult(resultCode); + } + } + + // ====================== 初始化相关方法 ====================== + private void initBaseParams() { + LogUtils.d(TAG, "初始化基础参数:工具类+私有目录+File"); + + // 私有目录(无需权限,无UID冲突) + mAppPrivateDirPath = getExternalFilesDir(Environment.DIRECTORY_PICTURES).getAbsolutePath() + "/PowerBellTest/"; + File privateDir = new File(mAppPrivateDirPath); + if (!privateDir.exists()) { + privateDir.mkdirs(); + LogUtils.d(TAG, "创建私有目录:" + mAppPrivateDirPath); + } + + // 初始化File(无Uri) + File refFile = new File(ASSETS_TEST_IMAGE_PATH); + String uniqueTestName = FileUtils.createUniqueFileName(refFile) + ".png"; + String uniqueCropName = uniqueTestName.replace(".png", "_crop.png"); + mPrivateTestImageFile = new File(mAppPrivateDirPath, uniqueTestName); + mPrivateCropImageFile = new File(mAppPrivateDirPath, uniqueCropName); + + LogUtils.d(TAG, "测试图File路径:" + mPrivateTestImageFile.getAbsolutePath()); + } + + private void initViewAndEvent() { + LogUtils.d(TAG, "初始化布局与控件事件"); + setContentView(R.layout.activity_mainunittest); + mBackgroundView = (BackgroundView) findViewById(R.id.backgroundview); + + // 跳转主页面按钮 + Button btnMain = (Button) findViewById(R.id.btn_main_activity); + btnMain.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "点击按钮:跳转主页面"); + startActivity(new Intent(MainUnitTestActivity.this, MainActivity.class)); + } + }); + + // 裁剪按钮(直接用File路径启动,无Uri) + Button btnCrop = (Button) findViewById(R.id.btn_test_cropimage); + btnCrop.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "点击按钮:启动裁剪(File路径版)"); + ToastUtils.show("准备启动图片裁剪"); + + if (mPrivateTestImageFile.exists() && mPrivateTestImageFile.length() > 100) { + startCropTestByFile(); // 直接传File + } else { + ToastUtils.show("测试图片未准备好,重新拷贝"); + copyAssetsTestImageToPrivateDir(); + } + } + }); + } + + // 从assets拷贝图片(不变,确保File存在) + private void copyAssetsTestImageToPrivateDir() { + LogUtils.d(TAG, "开始拷贝assets图片到私有目录"); + if (mPrivateTestImageFile.exists() && mPrivateTestImageFile.length() > 100) { + LogUtils.d(TAG, "图片已存在,无需拷贝"); + return; + } + + InputStream inputStream = null; + try { + inputStream = getAssets().open(ASSETS_TEST_IMAGE_PATH); + FileUtils.copyStreamToFile(inputStream, mPrivateTestImageFile); + LogUtils.d(TAG, "图片拷贝成功,大小:" + mPrivateTestImageFile.length() + "字节"); + } catch (IOException e) { + LogUtils.e(TAG, "图片拷贝失败:" + e.getMessage(), e); + ToastUtils.show("图片准备失败"); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + LogUtils.e(TAG, "关闭流失败:" + e.getMessage()); } } - if (allGranted) { - ToastUtils.show("存储权限已授予,启动裁剪"); - startCropTest(); // 权限授予后启动裁剪 - } else { - ToastUtils.show("存储权限被拒绝,无法启动裁剪"); - LogUtils.e(TAG, "【裁剪测试】存储权限被拒绝"); - } } } - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - LogUtils.d(TAG, "【裁剪回调】requestCode:" + requestCode + ",resultCode:" + resultCode + ",data:" + (data == null ? "null" : data.toString())); - ToastUtils.show(String.format("requestCode %d, resultCode %d, data is %s",requestCode, resultCode, data == null)); - // 裁剪完成后回收权限 - if (requestCode == REQUEST_CROP_IMAGE) { - String dstOutputPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) - + "/PowerBell/unittest/1764946782079-crop.jpeg"; - //Uri outputUri = ImageCropUtils.getFileProviderUriPublic(this, new File(dstOutputPath)); - //ImageCropUtils.releaseCropPermission(this, outputUri); - mBackgroundView.loadImage(dstOutputPath); + // ====================== 核心业务方法(全改为File路径) ====================== + /** 直接用File路径加载背景图(无Uri,无冲突) */ +// private void loadBackgroundByFile() { +// LogUtils.d(TAG, "开始加载背景图(File路径版)"); +// if (mPrivateTestImageFile.exists() && mPrivateTestImageFile.length() > 100) { +// mBackgroundView.loadImage(mPrivateTestImageFile.getAbsolutePath()); // 直接传路径 +// LogUtils.d(TAG, "背景图加载成功:" + mPrivateTestImageFile.getAbsolutePath()); +// ToastUtils.show("背景图加载成功"); +// } else { +// LogUtils.e(TAG, "背景图加载失败:文件无效"); +// ToastUtils.show("背景图加载失败"); +// } +// } + + /** 直接用File启动裁剪(关键:调用ImageCropUtils的File重载方法) */ + private void startCropTestByFile() { + LogUtils.d(TAG, "启动裁剪(File路径版),原图:" + mPrivateTestImageFile.getAbsolutePath()); + + // 确保输出目录存在 + File cropParent = mPrivateCropImageFile.getParentFile(); + if (!cropParent.exists()) { + cropParent.mkdirs(); } - } - - void loadBackground() { - // 校验测试图片是否存在(避免路径错误) - File testFile = new File(szTestSource); - if (testFile.exists() && testFile.length() > 100) { - mBackgroundView.loadImage(szTestSource); - LogUtils.d(TAG, "【图片加载】测试图片加载成功:" + szTestSource); + + // 调用ImageCropUtils的File参数方法(核心:绕开Uri) + ImageCropUtils.startImageCrop( + this, + mPrivateTestImageFile, // 原图File + mPrivateCropImageFile, // 输出File + 0, + 0, + true, + REQUEST_CROP_IMAGE + ); + + LogUtils.d(TAG, "裁剪请求已发送,输出路径:" + mPrivateCropImageFile.getAbsolutePath()); + ToastUtils.show("已启动图片裁剪"); + } + + /** 处理裁剪结果(直接校验输出File) */ + private void handleCropResult(int resultCode) { + LogUtils.d(TAG, "裁剪回调处理:resultCode=" + resultCode); + if (resultCode == RESULT_OK) { + if (mPrivateCropImageFile.exists() && mPrivateCropImageFile.length() > 100) { + mBackgroundView.loadImage(mPrivateCropImageFile.getAbsolutePath()); + LogUtils.d(TAG, "裁剪成功,加载裁剪图:" + mPrivateCropImageFile.getAbsolutePath()); + ToastUtils.show("裁剪成功"); + mPreviewBackgroundBean.setIsUseBackgroundScaledCompressFile(true); + doubleRefreshPreview(); + } else { + LogUtils.e(TAG, "裁剪成功但输出文件无效"); + ToastUtils.show("裁剪失败:输出文件无效"); + } + } else if (resultCode == RESULT_CANCELED) { + LogUtils.d(TAG, "裁剪取消"); + ToastUtils.show("裁剪已取消"); } else { - ToastUtils.show("测试图片不存在或无效"); - LogUtils.e(TAG, "【图片加载】测试图片无效:" + szTestSource); + LogUtils.e(TAG, "裁剪失败:resultCode异常"); + ToastUtils.show("裁剪失败"); } - } + } + + + /** + * 双重刷新预览,确保背景加载最新数据 + * 移除:缓存清空逻辑 + */ + private void doubleRefreshPreview() { + + // 第一重刷新 + try { + mBackgroundView.loadBackgroundBean(mPreviewBackgroundBean, true); + mBackgroundView.setBackgroundColor(mPreviewBackgroundBean.getPixelColor()); + LogUtils.d(TAG, "【双重刷新】第一重完成"); + } catch (Exception e) { + LogUtils.e(TAG, "【双重刷新】第一重异常:" + e.getMessage()); + return; + } + + // 第二重刷新(延迟执行) + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + if (mBackgroundView != null && !isFinishing()) { + try { + mBackgroundView.loadBackgroundBean(mPreviewBackgroundBean, true); + mBackgroundView.setBackgroundColor(mPreviewBackgroundBean.getPixelColor()); + LogUtils.d(TAG, "【双重刷新】第二重完成"); + } catch (Exception e) { + LogUtils.e(TAG, "【双重刷新】第二重异常:" + e.getMessage()); + } + } + } + }, 200); + } } 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 fedd1dc..4ab4a4d 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 @@ -211,7 +211,7 @@ public class BackgroundSourceUtils { LogUtils.d(TAG, "背景Bean文件存在,无需创建空白背景"); return false; } - + String genNewCropFileName() { return UUID.randomUUID().toString() + System.currentTimeMillis(); } @@ -231,10 +231,12 @@ public class BackgroundSourceUtils { } Uri uri = UriUtils.getUriForFile(mContext, oldPreviewBackgroundBean.getBackgroundFilePath()); - String fileSuffix = FileUtils.getFileSuffix(mContext, uri); + LogUtils.d(TAG, String.format("createAndUpdatePreviewEnvironmentForCropping: uri %s", uri)); + String fileSuffix = UriUtils.getSuffixFromUri(mContext, uri); + LogUtils.d(TAG, String.format("createAndUpdatePreviewEnvironmentForCropping: fileSuffix = %s", fileSuffix)); String newCropFileName = genNewCropFileName(); - mCropSourceFile = new File(fCropCacheDir, newCropFileName + "." + fileSuffix); - mCropResultFile = new File(fCropCacheDir, "SelectCompress_" + newCropFileName + "." + fileSuffix); + mCropSourceFile = new File(fCropCacheDir, newCropFileName + File.separator + fileSuffix); + mCropResultFile = new File(fCropCacheDir, "SelectCompress_" + newCropFileName + ".png"); if (FileUtils.isFileExists(oldPreviewBackgroundBean.getBackgroundScaledCompressFilePath())) { FileUtils.copyFile(new File(oldPreviewBackgroundBean.getBackgroundScaledCompressFilePath()), mCropResultFile); diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/FileUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/FileUtils.java index 78bdb06..e76e140 100644 --- a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/FileUtils.java +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/FileUtils.java @@ -263,24 +263,28 @@ public class FileUtils { } } - public static String getFileSuffix(Context context, Uri uri){ - String szType = context.getContentResolver().getType(uri); - // 2. 截取MIME类型后缀(如从image/jpeg中提取jpeg)【核心新增逻辑】 - String fileSuffix = ""; - if (szType != null && szType.contains("/")) { - // 分割字符串,取"/"后面的部分(如"image/jpeg" → 分割后取索引1的"jpeg") - fileSuffix = szType.split("/")[1]; - // 调试日志:打印截取后的文件后缀 - } else { - // 异常处理:若类型为空或格式错误,默认后缀设为jpeg(保留原逻辑兼容性) - fileSuffix = "jpeg"; - } - return fileSuffix; - } - public static boolean isFileExists(String path) { File file = new File(path); return file.exists(); } + + /** + * 获取文件后缀(不带点,忽略大小写,适配空文件名/无后缀场景) + * @param file 目标文件 + * @return 后缀字符串(无后缀返回空字符串,非空统一小写) + */ + public static String getFileSuffix(File file) { + if (file == null || file.getName().isEmpty()) { + return ""; // 空文件/空文件名,返回空 + } + String fileName = file.getName(); + int lastDotIndex = fileName.lastIndexOf("."); + // 无后缀(没有点,或点在开头/结尾) + if (lastDotIndex == -1 || lastDotIndex == 0 || lastDotIndex == fileName.length() - 1) { + return ""; + } + // 截取后缀并转小写(统一格式,避免 PNG/png 差异) + return fileName.substring(lastDotIndex + 1).toLowerCase(); + } } diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageCropUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageCropUtils.java index 5e00ea2..80a7254 100644 --- a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageCropUtils.java +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageCropUtils.java @@ -6,42 +6,100 @@ import android.net.Uri; import android.os.Build; import androidx.core.content.FileProvider; import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.R; import cc.winboll.studio.powerbell.models.BackgroundBean; import com.yalantis.ucrop.UCrop; -import com.yalantis.ucrop.UCropActivity; import java.io.File; -import cc.winboll.studio.powerbell.R; /** - * 图片裁剪工具类(集成uCrop,脱离系统依赖) + * 图片裁剪工具类(集成 uCrop 2.2.8 终极兼容版,强制输出 PNG 格式,全程保留透明通道,支持 Uri/File 双传参) */ public class ImageCropUtils { public static final String TAG = "ImageCropUtils"; - // FileProvider 授权(与项目一致) + // FileProvider 授权(与 AndroidManifest 配置一致) private static final String FILE_PROVIDER_SUFFIX = ".fileprovider"; + // 强制输出格式:固定为 PNG(保留透明通道) + private static final String FORCE_OUTPUT_SUFFIX = "png"; + private static final android.graphics.Bitmap.CompressFormat FORCE_COMPRESS_FORMAT = android.graphics.Bitmap.CompressFormat.PNG; + + // ====================== 核心裁剪方法(强制 PNG 输出,优化逻辑)====================== + /** + * 【Uri 传参版】启动 uCrop 裁剪 - 强制输出 PNG,保留透明通道 + * @param activity 上下文 + * @param inputUri 输入图片 Uri(本应用 FileProvider Uri,非空) + * @param outputUri 输出图片 Uri(本应用 FileProvider Uri,非空) + * @param aspectX 固定比例 X(自由裁剪传 0) + * @param aspectY 固定比例 Y(自由裁剪传 0) + * @param isFreeCrop 是否自由裁剪 + * @param requestCode 裁剪请求码 + */ + public static void startImageCrop(Activity activity, + Uri inputUri, + Uri outputUri, + int aspectX, + int aspectY, + boolean isFreeCrop, + int requestCode) { + // 1. 输入参数校验 + if (activity == null || activity.isFinishing()) { + LogUtils.e(TAG, "【裁剪异常】Activity 无效或已销毁"); + return; + } + if (inputUri == null || outputUri == null) { + LogUtils.e(TAG, "【裁剪异常】输入/输出 Uri 为空"); + showToast(activity, "图片 Uri 无效,无法裁剪"); + return; + } + if (!isValidUri(activity, inputUri)) { + LogUtils.e(TAG, "【裁剪异常】输入 Uri 无效:" + inputUri); + showToast(activity, "原图 Uri 无效,无法裁剪"); + return; + } + + // 2. 核心:强制修正输出为 PNG(忽略原图格式,统一转 PNG) + File outputFile = uriToFile(activity, outputUri); + if (outputFile == null) { + LogUtils.e(TAG, "【裁剪异常】输出 Uri 转 File 失败:" + outputUri); + showToast(activity, "裁剪输出路径无效"); + return; + } + outputFile = correctFileSuffix(outputFile, FORCE_OUTPUT_SUFFIX); // 强制 .png 后缀 + outputUri = getFileProviderUri(activity, outputFile); // 重新生成 PNG 对应的 Uri + + // 3. 初始化 uCrop + 强制 PNG 配置(保留透明核心) + UCrop uCrop = UCrop.of(inputUri, outputUri); + UCrop.Options options = initCropOptions(activity, isFreeCrop, aspectX, aspectY); // 移除 isPng 参数 + + // 4. 启动裁剪 + uCrop.withOptions(options); + uCrop.start(activity, requestCode); + LogUtils.d(TAG, "【裁剪启动成功(Uri 版)】强制输出 PNG(透明保留),输出路径:" + outputFile.getAbsolutePath()); + } /** - * 启动uCrop裁剪(核心方法,替代系统裁剪) + * 【File 传参版】启动 uCrop 裁剪 - 强制输出 PNG,保留透明通道 * @param activity 上下文 - * @param inputFile 输入图片文件 - * @param outputFile 输出图片文件 - * @param isFreeCrop 是否自由裁剪(true=自由,false=固定比例) + * @param inputFile 输入图片文件(任意格式) + * @param outputFile 输出图片文件(最终强制转为 PNG) + * @param aspectX 固定比例 X(自由裁剪传 0) + * @param aspectY 固定比例 Y(自由裁剪传 0) + * @param isFreeCrop 是否自由裁剪 * @param requestCode 裁剪请求码 */ public static void startImageCrop(Activity activity, File inputFile, File outputFile, - int aspectX, + int aspectX, int aspectY, boolean isFreeCrop, int requestCode) { - // 校验输入参数 + // 1. 输入参数校验 if (activity == null || activity.isFinishing()) { - LogUtils.e(TAG, "【裁剪异常】上下文Activity无效"); + LogUtils.e(TAG, "【裁剪异常】Activity 无效或已销毁"); return; } if (inputFile == null || !inputFile.exists() || inputFile.length() <= 100) { - LogUtils.e(TAG, "【裁剪异常】输入文件无效"); + LogUtils.e(TAG, "【裁剪异常】输入图片文件无效"); showToast(activity, "无有效图片可裁剪"); return; } @@ -51,47 +109,27 @@ public class ImageCropUtils { return; } - // 生成输入/输出Uri(适配FileProvider) + // 2. 核心:强制修正输出为 PNG(忽略原图格式) Uri inputUri = getFileProviderUri(activity, inputFile); - Uri outputUri = Uri.fromFile(outputFile); // uCrop 支持直接用文件Uri(兼容低版本) + outputFile = correctFileSuffix(outputFile, FORCE_OUTPUT_SUFFIX); // 强制 .png 后缀 + Uri outputUri = getFileProviderUri(activity, outputFile); - // 配置uCrop参数 + // 3. 初始化 uCrop + 强制 PNG 配置 UCrop uCrop = UCrop.of(inputUri, outputUri); - UCrop.Options options = new UCrop.Options(); + UCrop.Options options = initCropOptions(activity, isFreeCrop, aspectX, aspectY); // 移除 isPng 参数 - // 裁剪模式配置(自由裁剪/固定比例) - if (isFreeCrop) { - // 自由裁剪:无固定比例,可随意调整 - uCrop.withAspectRatio(0, 0); - options.setFreeStyleCropEnabled(true); // 开启自由裁剪 - } else { - // 固定比例(默认1:1,可根据需求修改) - uCrop.withAspectRatio(aspectX, aspectY); - options.setFreeStyleCropEnabled(false); - } - - // 裁剪配置(优化体验) - options.setCompressionFormat(android.graphics.Bitmap.CompressFormat.JPEG); // 输出格式 - options.setCompressionQuality(100); // 图片质量 - options.setHideBottomControls(true); // 隐藏底部控制栏(简化界面) - options.setToolbarTitle("图片裁剪"); // 工具栏标题 - options.setToolbarColor(activity.getResources().getColor(R.color.colorPrimary)); // 工具栏颜色(适配项目主题) - options.setStatusBarColor(activity.getResources().getColor(R.color.colorPrimaryDark)); // 状态栏颜色 - - // 应用配置并启动裁剪 + // 4. 启动裁剪 uCrop.withOptions(options); - // 启动uCrop裁剪Activity(替代系统裁剪) uCrop.start(activity, requestCode); - - LogUtils.d(TAG, "【uCrop启动】成功,输入Uri:" + inputUri + ",输出Uri:" + outputUri + ",请求码:" + requestCode); + LogUtils.d(TAG, "【裁剪启动成功(File 版)】强制输出 PNG(透明保留),输出路径:" + outputFile.getAbsolutePath()); } /** - * 重载方法:适配BackgroundBean + * 【BackgroundBean 传参版】启动 uCrop 裁剪 - 强制输出 PNG,保留透明通道 */ public static void startImageCrop(Activity activity, BackgroundBean cropBean, - int aspectX, + int aspectX, int aspectY, boolean isFreeCrop, int requestCode) { @@ -100,70 +138,153 @@ public class ImageCropUtils { startImageCrop(activity, inputFile, outputFile, aspectX, aspectY, isFreeCrop, requestCode); } - /** - * 生成FileProvider Uri - */ - private static Uri getFileProviderUri(Activity activity, File file) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - String authority = activity.getPackageName() + FILE_PROVIDER_SUFFIX; - Uri uri = FileProvider.getUriForFile(activity, authority, file); - LogUtils.d(TAG, "【Uri生成】FileProvider Uri:" + uri); - return uri; - } else { - Uri uri = Uri.fromFile(file); - LogUtils.d(TAG, "【Uri生成】普通Uri:" + uri); - return uri; + // ====================== 裁剪结果处理(保持兼容,优化日志)====================== + public static String handleCropResult(int requestCode, int resultCode, Intent data, int cropRequestCode) { + if (requestCode != cropRequestCode) return null; + + if (resultCode == Activity.RESULT_OK && data != null) { + Uri outputUri = UCrop.getOutput(data); + if (outputUri != null) { + String outputPath = uriToPath(outputUri); + LogUtils.d(TAG, "【裁剪成功】强制输出 PNG(透明保留),输出路径:" + outputPath); + return outputPath; } + } else if (resultCode == UCrop.RESULT_ERROR) { + Throwable error = UCrop.getError(data); + LogUtils.e(TAG, "【裁剪失败】原因:" + (error != null ? error.getMessage() : "未知错误")); + } else { + LogUtils.d(TAG, "【裁剪取消】用户手动取消"); + } + return null; + } + + // ====================== 辅助方法(优化适配强制 PNG 逻辑)====================== + /** 校验 Uri 有效性(确保是图片类型) */ + private static boolean isValidUri(Activity activity, Uri uri) { + try { + String type = activity.getContentResolver().getType(uri); + return type != null && type.startsWith("image/"); } catch (Exception e) { - LogUtils.e(TAG, "【Uri生成】失败:" + e.getMessage()); + LogUtils.e(TAG, "【Uri 校验失败】原因:" + e.getMessage()); + return false; + } + } + + /** Uri 转 File(适配 FileProvider Uri 和普通 Uri) */ + private static File uriToFile(Activity activity, Uri uri) { + if (uri == null) return null; + try { + if (uri.getScheme().equals("file")) { + return new File(uri.getPath()); + } + String filePath = uri.getPath(); + if (filePath == null) return null; + if (filePath.contains("/external_files/")) { + filePath = filePath.replace("/external_files/", activity.getExternalFilesDir("").getAbsolutePath() + "/"); + } else if (filePath.contains("/cache/")) { + filePath = filePath.replace("/cache/", activity.getCacheDir().getAbsolutePath() + "/"); + } + return new File(filePath); + } catch (Exception e) { + LogUtils.e(TAG, "【Uri 转 File 失败】uri=" + uri + ",原因:" + e.getMessage()); + return null; + } + } + + /** Uri 提取文件路径 */ + private static String uriToPath(Uri uri) { + if (uri == null) return null; + try { + if (uri.getScheme().equals("file")) { + return uri.getPath(); + } + String path = uri.getPath(); + if (path == null) return null; + String[] prefixes = {"/external/", "/external_files/", "/cache/", "/files/"}; + for (String prefix : prefixes) { + if (path.contains(prefix)) { + path = path.substring(path.indexOf(prefix) + prefix.length()); + String externalRoot = android.os.Environment.getExternalStorageDirectory().getAbsolutePath(); + return externalRoot + "/" + path; + } + } + return path; + } catch (Exception e) { + LogUtils.e(TAG, "【Uri 转路径失败】uri=" + uri + ",原因:" + e.getMessage()); return null; } } /** - * 处理uCrop裁剪回调(在Activity的onActivityResult中调用) - * @param requestCode 请求码 - * @param resultCode 结果码 - * @param data 回调数据 - * @return 裁剪成功返回输出文件路径,失败返回null + * 统一初始化裁剪配置(强制 PNG 专属配置,保留透明核心) + * 移除 isPng 参数,全程用 PNG 配置 */ - public static String handleCropResult(int requestCode, int resultCode, Intent data, int cropRequestCode) { - // 校验是否是uCrop的回调 - if (requestCode == cropRequestCode) { - if (resultCode == Activity.RESULT_OK && data != null) { - // 裁剪成功,获取输出Uri - Uri outputUri = UCrop.getOutput(data); - if (outputUri != null) { - String outputPath = outputUri.getPath(); - LogUtils.d(TAG, "【uCrop回调】裁剪成功,输出路径:" + outputPath); - return outputPath; - } - } else if (resultCode == UCrop.RESULT_ERROR) { - // 裁剪失败,获取异常信息 - Throwable error = UCrop.getError(data); - LogUtils.e(TAG, "【uCrop回调】裁剪失败:" + (error != null ? error.getMessage() : "未知错误")); - } else { - LogUtils.d(TAG, "【uCrop回调】裁剪被取消"); - } - } - return null; + private static UCrop.Options initCropOptions(Activity activity, boolean isFreeCrop, int aspectX, int aspectY) { + UCrop.Options options = new UCrop.Options(); + + // 1. 裁剪模式配置 + options.setFreeStyleCropEnabled(isFreeCrop); + + // 2. 核心:强制 PNG 保留透明(固定配置,无需判断原图格式) + options.setCompressionFormat(FORCE_COMPRESS_FORMAT); // 强制 PNG 压缩 + options.setCompressionQuality(100); // PNG 100% 质量,不损失透明 + options.setDimmedLayerColor(activity.getResources().getColor(android.R.color.transparent)); // 遮罩透明(关键) + options.setCropFrameColor(activity.getResources().getColor(R.color.colorPrimary)); // 裁剪框主题色 + options.setCropGridColor(activity.getResources().getColor(R.color.colorAccent)); // 网格线主题色 + + // 3. 通用 UI 配置(保持原有风格) + options.setHideBottomControls(true); // 隐藏底部控制栏 + options.setToolbarTitle("图片裁剪"); + options.setToolbarColor(activity.getResources().getColor(R.color.colorPrimary)); + options.setToolbarWidgetColor(activity.getResources().getColor(android.R.color.white)); + options.setStatusBarColor(activity.getResources().getColor(R.color.colorPrimaryDark)); + + return options; } /** - * 显示Toast + * 修正文件后缀(强制转为 .png,覆盖原有任何图片后缀) */ + private static File correctFileSuffix(File originFile, String targetSuffix) { + String originName = originFile.getName(); + // 强制替换所有图片后缀为 targetSuffix(避免漏改) + originName = originName.replaceAll("\\.(jpg|jpeg|png|bmp|gif)$", "") + "." + targetSuffix; + return new File(originFile.getParent(), originName); + } + + /** 生成 FileProvider Uri(适配 Android 7.0+) */ + private static Uri getFileProviderUri(Activity activity, File file) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + String authority = activity.getPackageName() + FILE_PROVIDER_SUFFIX; + return FileProvider.getUriForFile(activity, authority, file); + } else { + return Uri.fromFile(file); + } + } catch (Exception e) { + LogUtils.e(TAG, "【Uri 生成失败】原因:" + e.getMessage()); + return null; + } + } + + /** 显示 Toast(避免崩溃) */ private static void showToast(Activity activity, String msg) { if (activity != null && !activity.isFinishing()) { android.widget.Toast.makeText(activity, msg, android.widget.Toast.LENGTH_SHORT).show(); } } - /** - * 暴露getFileProviderUri方法(供外部调用) - */ + // ====================== 公有辅助方法(供外部调用)====================== public static Uri getFileProviderUriPublic(Activity activity, File file) { return getFileProviderUri(activity, file); } + + public static File getFileFromUriPublic(Activity activity, Uri uri) { + return uriToFile(activity, uri); + } + + public static String getPathFromUriPublic(Uri uri) { + return uriToPath(uri); + } } diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/PermissionUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/PermissionUtils.java index a336d7f..ac17719 100644 --- a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/PermissionUtils.java +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/PermissionUtils.java @@ -5,31 +5,43 @@ import android.app.AlertDialog; import android.content.ComponentName; import android.content.DialogInterface; import android.content.Intent; +import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; +import android.os.Environment; import android.os.PowerManager; import android.provider.Settings; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import cc.winboll.studio.libaes.dialogs.YesNoAlertDialog; import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.MainActivity; +import cc.winboll.studio.powerbell.R; /** * @Author ZhanGSKen&豆包大模型 * @Date 2025/12/14 03:05 * @Describe 权限申请工具类(Java7兼容版) - * 适配 小米手机+API30,专注自启动权限、电池优化权限检查与申请 + * 适配 小米手机+API29-30,整合自启动、电池优化、全文件管理权限,专注后台保活核心权限 */ public class PermissionUtils { - // ====================== 常量定义(统一管理,首屏可见)====================== + // ====================== 常量定义(首屏可见,统一管理,避免冲突)====================== + // 日志标签 public static final String TAG = "PermissionUtils"; - // 权限请求码(仅保留核心权限场景) + // 权限请求码(按场景分段,避免重复) public static final int REQUEST_IGNORE_BATTERY_OPTIMIZATION = 1000; // 电池优化权限 public static final int REQUEST_AUTO_START = 1001; // 自启动权限(小米专属) - // SDK版本常量(适配API30,替代系统枚举) + public static final int REQUEST_ALL_FILE_MANAGE = 1002; // 全文件管理权限(API30+) + // SDK版本常量(适配API29-30,替代系统枚举,Java7兼容) + private static final int SDK_VERSION_Q = 29; // Android 10(API29) private static final int SDK_VERSION_R = 30; // Android 11(API30) - // 小米手机自启动权限页面包名/类名(小米专属跳转路径) + // 小米自启动权限页面配置(专属跳转路径,精准适配) private static final String XIAOMI_AUTO_START_PACKAGE = "com.miui.securitycenter"; private static final String XIAOMI_AUTO_START_CLASS = "com.miui.permcenter.autostart.AutoStartManagementActivity"; - // ====================== 单例模式(Java7标准双重校验锁)====================== + // ====================== 单例模式(Java7标准双重校验锁,线程安全+懒加载)====================== private static volatile PermissionUtils sInstance; private PermissionUtils() {} @@ -39,81 +51,148 @@ public class PermissionUtils { synchronized (PermissionUtils.class) { if (sInstance == null) { sInstance = new PermissionUtils(); - LogUtils.d(TAG, "【单例初始化】PermissionUtils 实例创建成功"); + LogUtils.d(TAG, "初始化:PermissionUtils 单例创建成功"); } } } return sInstance; } - // ====================== 自启动权限(拆分检查+请求,小米专属)====================== + // ====================== 核心权限1:全文件管理权限(API29-30适配,通用所有机型)====================== /** - * 检查是否拥有自启动权限(小米手机专属判断,API30适配) - * 注:小米自启动无系统API直接校验,通过「是否为小米机型」+「功能场景间接判断」,此处返回机型适配状态 + * 检查全文件管理权限(适配API30+ MANAGE_EXTERNAL_STORAGE,兼容API29-旧权限) * @param activity 上下文Activity(不可为null) - * @return true:小米机型(需手动开启权限);false:非小米机型(无需申请) + * @return true=权限已授予,false=权限未授予 */ - public boolean checkAutoStartPermission(Activity activity) { + public boolean checkAllFileManagePermission(Activity activity) { + LogUtils.d(TAG, "全文件权限-检查:开始校验,系统版本=" + Build.VERSION.SDK_INT); if (activity == null) { - LogUtils.e(TAG, "【自启动权限-检查】失败:Activity为空"); + LogUtils.e(TAG, "全文件权限-检查:失败,Activity为空"); return false; } - LogUtils.d(TAG, "【自启动权限-检查】开始,设备品牌:" + Build.BRAND + ",系统版本:" + Build.VERSION.SDK_INT); - // 仅小米机型需要申请自启动权限,非小米直接返回false(无需处理) - boolean isXiaomi = Build.BRAND.toLowerCase().contains("xiaomi"); - LogUtils.d(TAG, "【自启动权限-检查】结果:" + (isXiaomi ? "小米机型(需手动开启)" : "非小米机型(无需申请)")); - return isXiaomi; + // API30+:校验 MANAGE_EXTERNAL_STORAGE 特殊权限 + if (Build.VERSION.SDK_INT >= SDK_VERSION_R) { + boolean hasManagePerm = Environment.isExternalStorageManager(); + LogUtils.d(TAG, "全文件权限-检查:API30+,MANAGE_EXTERNAL_STORAGE权限=" + (hasManagePerm ? "已授予" : "未授予")); + return hasManagePerm; + } else if (Build.VERSION.SDK_INT == SDK_VERSION_Q) { + LogUtils.d(TAG, "全文件权限-检查:API29,无需申请,默认支持文件管理"); + return true; + } else { + boolean hasWritePerm = ContextCompat.checkSelfPermission(activity, + android.Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + LogUtils.d(TAG, "全文件权限-检查:API29以下,WRITE_EXTERNAL_STORAGE权限=" + (hasWritePerm ? "已授予" : "未授予")); + return hasWritePerm; + } } /** - * 请求自启动权限(小米手机专属,API30适配,跳转系统页面引导开启) + * 申请全文件管理权限(适配API30+特殊权限流程,兼容API29-旧权限申请) + * @param activity 申请权限的Activity(不可为null) + */ + public void requestAllFileManagePermission(Activity activity) { + LogUtils.d(TAG, "全文件权限-申请:开始处理,系统版本=" + Build.VERSION.SDK_INT); + if (activity == null || activity.isFinishing()) { + LogUtils.e(TAG, "全文件权限-申请:失败,Activity无效/已销毁"); + return; + } + + // 先检查权限,已授予直接返回 + if (checkAllFileManagePermission(activity)) { + LogUtils.d(TAG, "全文件权限-申请:已拥有权限,无需发起"); + return; + } + + // API30+:跳转系统特殊权限申请页(用户手动授权) + if (Build.VERSION.SDK_INT >= SDK_VERSION_R) { + try { + Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); + intent.setData(Uri.parse("package:" + activity.getPackageName())); + activity.startActivityForResult(intent, REQUEST_ALL_FILE_MANAGE); + LogUtils.d(TAG, "全文件权限-申请:API30+,跳转特殊权限申请页"); + } catch (Exception e) { + // 备用跳转:系统设置首页,引导手动操作 + Intent intent = new Intent(Settings.ACTION_SETTINGS); + activity.startActivityForResult(intent, REQUEST_ALL_FILE_MANAGE); + LogUtils.w(TAG, "全文件权限-申请:跳转失败,引导手动开启"); + showAllFileManageTipsDialog(activity); + } + } else { + ActivityCompat.requestPermissions(activity, + new String[]{android.Manifest.permission.WRITE_EXTERNAL_STORAGE}, + REQUEST_ALL_FILE_MANAGE); + LogUtils.d(TAG, "全文件权限-申请:API29以下,发起WRITE_EXTERNAL_STORAGE权限申请"); + } + } + + // ====================== 核心权限2:自启动权限(小米专属,API29-30适配)====================== + /** + * 检查自启动权限(仅小米机型需要,非小米直接返回无需申请) + * @param activity 上下文Activity(不可为null) + * @return true=小米机型(需手动开启);false=非小米机型(无需申请) + */ +// public boolean checkAutoStartPermission(Activity activity) { +// LogUtils.d(TAG, "自启动权限-检查:开始,设备品牌=" + Build.BRAND); +// if (activity == null) { +// LogUtils.e(TAG, "自启动权限-检查:失败,Activity为空"); +// return false; +// } +// +// boolean isXiaomi = Build.BRAND.toLowerCase().contains("xiaomi"); +// LogUtils.d(TAG, "自启动权限-检查:结果=" + (isXiaomi ? "小米机型(需开启)" : "非小米机型(无需申请)")); +// return isXiaomi; +// } + + /** + * 请求自启动权限(小米专属,多方案跳转,适配API29-30机型差异) * @param activity 申请权限的Activity(不可为null) */ public void requestAutoStartPermission(Activity activity) { - if (activity == null) { - LogUtils.e(TAG, "【自启动权限-请求】失败:Activity为空"); + LogUtils.d(TAG, "自启动权限-申请:开始处理"); + if (activity == null || activity.isFinishing()) { + LogUtils.e(TAG, "自启动权限-申请:失败,Activity无效/已销毁"); return; } - // 先检查机型,非小米不执行请求 - if (!checkAutoStartPermission(activity)) { - LogUtils.d(TAG, "【自启动权限-请求】非小米机型,无需发起请求"); - return; - } - LogUtils.d(TAG, "【自启动权限-请求】开始处理,系统版本:" + Build.VERSION.SDK_INT); - // API30+ 小米手机:优先精准跳转自启动管理页 + // 非小米机型,直接返回 +// if (!checkAutoStartPermission(activity)) { +// LogUtils.d(TAG, "自启动权限-申请:非小米机型,无需处理"); +// return; +// } + + // API30+ 小米:优先精准跳转自启动管理页 if (Build.VERSION.SDK_INT >= SDK_VERSION_R) { try { // 方案1:组件名精准跳转(成功率最高) Intent intent = new Intent(); intent.setComponent(new ComponentName(XIAOMI_AUTO_START_PACKAGE, XIAOMI_AUTO_START_CLASS)); activity.startActivityForResult(intent, REQUEST_AUTO_START); - LogUtils.d(TAG, "【自启动权限-请求】跳转小米自启动管理页(组件名跳转)"); + LogUtils.d(TAG, "自启动权限-申请:API30+,组件名跳转自启动管理页"); } catch (Exception e1) { try { // 方案2:Action备用跳转(兼容机型差异) Intent intent = new Intent("miui.intent.action.OP_AUTO_START"); intent.setClassName(XIAOMI_AUTO_START_PACKAGE, XIAOMI_AUTO_START_CLASS); activity.startActivityForResult(intent, REQUEST_AUTO_START); - LogUtils.d(TAG, "【自启动权限-请求】跳转小米自启动管理页(Action跳转)"); + LogUtils.d(TAG, "自启动权限-申请:API30+,Action跳转自启动管理页"); } catch (Exception e2) { - // 方案3:终极备用(跳转系统设置,引导手动操作) + // 方案3:终极备用,跳转系统设置+提示 Intent intent = new Intent(Settings.ACTION_SETTINGS); activity.startActivityForResult(intent, REQUEST_AUTO_START); - LogUtils.w(TAG, "【自启动权限-请求】跳转系统设置页(引导手动开启)"); + LogUtils.w(TAG, "自启动权限-申请:跳转失败,引导手动操作"); showAutoStartTipsDialog(activity); } } return; } - // API30以下小米手机:兼容低版本跳转逻辑 - LogUtils.d(TAG, "【自启动权限-请求】API30以下小米机型,执行低版本跳转"); + // API29 小米:低版本兼容跳转 try { Intent intent = new Intent(XIAOMI_AUTO_START_CLASS); intent.setPackage(XIAOMI_AUTO_START_PACKAGE); activity.startActivityForResult(intent, REQUEST_AUTO_START); + LogUtils.d(TAG, "自启动权限-申请:API29,低版本跳转自启动管理页"); } catch (Exception e) { Intent intent = new Intent(Settings.ACTION_SETTINGS); activity.startActivityForResult(intent, REQUEST_AUTO_START); @@ -121,75 +200,94 @@ public class PermissionUtils { } } - // ====================== 电池优化权限(拆分检查+请求,通用所有机型)====================== + // ====================== 核心权限3:电池优化权限(通用所有机型,API29-30适配)====================== /** - * 检查是否拥有「忽略电池优化」权限(API30适配,通用所有机型,精准返回权限状态) + * 检查忽略电池优化权限(精准判断,API23+有效,低版本视为已拥有) * @param activity 上下文Activity(不可为null) - * @return true:已拥有(忽略优化);false:未拥有(需申请) + * @return true=已忽略优化;false=未忽略(需申请) */ public boolean checkIgnoreBatteryOptimizationPermission(Activity activity) { + LogUtils.d(TAG, "电池优化权限-检查:开始,系统版本=" + Build.VERSION.SDK_INT); if (activity == null) { - LogUtils.e(TAG, "【电池优化权限-检查】失败:Activity为空"); + LogUtils.e(TAG, "电池优化权限-检查:失败,Activity为空"); return false; } - LogUtils.d(TAG, "【电池优化权限-检查】开始,系统版本:" + Build.VERSION.SDK_INT); - // API23以下无电池优化权限,直接视为已拥有 + // API23以下无此权限,视为已拥有 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - LogUtils.d(TAG, "【电池优化权限-检查】API23以下无此权限,视为已拥有"); + LogUtils.d(TAG, "电池优化权限-检查:API23以下,无需校验,视为已拥有"); return true; } // API23+ 精准校验权限状态 PowerManager powerManager = (PowerManager) activity.getSystemService(Activity.POWER_SERVICE); if (powerManager == null) { - LogUtils.e(TAG, "【电池优化权限-检查】获取PowerManager失败,无法校验"); + LogUtils.e(TAG, "电池优化权限-检查:获取PowerManager失败,校验异常"); return false; } boolean isIgnored = powerManager.isIgnoringBatteryOptimizations(activity.getPackageName()); - LogUtils.d(TAG, "【电池优化权限-检查】结果:" + (isIgnored ? "已拥有(忽略优化)" : "未拥有(需申请)")); + LogUtils.d(TAG, "电池优化权限-检查:结果=" + (isIgnored ? "已忽略优化" : "未忽略(需申请)")); return isIgnored; } /** - * 请求「忽略电池优化」权限(API30适配,通用所有机型,自动判断是否需要申请) + * 请求忽略电池优化权限(多方案跳转,适配API29-30,自动判断是否需要申请) * @param activity 申请权限的Activity(不可为null) */ public void requestIgnoreBatteryOptimizationPermission(Activity activity) { - if (activity == null) { - LogUtils.e(TAG, "【电池优化权限-请求】失败:Activity为空"); + LogUtils.d(TAG, "电池优化权限-申请:开始处理"); + if (activity == null || activity.isFinishing()) { + LogUtils.e(TAG, "电池优化权限-申请:失败,Activity无效/已销毁"); return; } - // 先检查权限,已拥有则直接返回 + + // 已拥有权限,直接返回 if (checkIgnoreBatteryOptimizationPermission(activity)) { - LogUtils.d(TAG, "【电池优化权限-请求】已拥有权限,无需发起申请"); + LogUtils.d(TAG, "电池优化权限-申请:已拥有权限,无需发起"); return; } - LogUtils.w(TAG, "【电池优化权限-请求】未拥有权限,开始发起申请"); try { - // 方案1:直接跳转权限申请页(用户一键同意,优先使用) + // 方案1:直接跳转一键授权页(优先使用,用户操作简单) Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); intent.setData(Uri.parse("package:" + activity.getPackageName())); activity.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATION); - LogUtils.d(TAG, "【电池优化权限-请求】跳转系统权限申请页"); + LogUtils.d(TAG, "电池优化权限-申请:跳转一键授权页"); } catch (Exception e) { - // 方案2:备用跳转(跳转优化管理列表,引导手动选择) + // 方案2:备用跳转优化管理页+提示 Intent intent = new Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS); activity.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATION); - LogUtils.w(TAG, "【电池优化权限-请求】跳转优化管理页(引导手动开启)"); + LogUtils.w(TAG, "电池优化权限-申请:跳转失败,引导手动操作"); showBatteryOptTipsDialog(activity); } } - // ====================== 辅助方法(提示弹窗+结果处理)====================== + // ====================== 辅助方法:手动开启提示弹窗(适配跳转失败场景)====================== /** - * 显示自启动权限手动开启提示弹窗(小米机型跳转失败时使用) + * 全文件管理权限手动开启提示弹窗 + */ + private void showAllFileManageTipsDialog(final Activity activity) { + new AlertDialog.Builder(activity) + .setTitle("全文件管理权限申请提示") + .setMessage("请手动开启全文件管理权限,否则文件操作功能异常:\n1. 进入设置 → 应用 → 本应用 → 权限\n2. 找到「文件管理」/「存储」权限,开启「允许管理所有文件」") + .setPositiveButton("知道了", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .setCancelable(false) + .show(); + LogUtils.d(TAG, "全文件权限:显示手动开启提示弹窗"); + } + + /** + * 自启动权限手动开启提示弹窗(小米专属) */ private void showAutoStartTipsDialog(final Activity activity) { new AlertDialog.Builder(activity) - .setTitle("权限申请提示") - .setMessage("请手动开启自启动权限,否则应用后台功能可能异常:\n1. 进入安全中心 → 应用管理 → 自启动管理\n2. 找到本应用,开启「允许自启动」开关") + .setTitle("自启动权限申请提示") + .setMessage("请手动开启自启动权限,否则应用后台保活异常:\n1. 进入小米安全中心 → 应用管理 → 自启动管理\n2. 找到本应用,开启「允许自启动」开关") .setPositiveButton("知道了", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { @@ -198,16 +296,16 @@ public class PermissionUtils { }) .setCancelable(false) .show(); - LogUtils.d(TAG, "【自启动权限】显示手动开启提示弹窗"); + LogUtils.d(TAG, "自启动权限:显示手动开启提示弹窗"); } /** - * 显示电池优化权限手动开启提示弹窗(跳转失败时使用) + * 电池优化权限手动开启提示弹窗 */ private void showBatteryOptTipsDialog(final Activity activity) { new AlertDialog.Builder(activity) - .setTitle("权限申请提示") - .setMessage("请手动忽略电池优化,否则应用后台运行可能被限制:\n1. 进入设置 → 电池 → 电池优化\n2. 找到本应用,选择「不优化」") + .setTitle("电池优化权限申请提示") + .setMessage("请手动忽略电池优化,否则应用后台运行被限制:\n1. 进入设置 → 电池 → 电池优化\n2. 找到本应用,选择「不优化」选项") .setPositiveButton("知道了", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { @@ -216,7 +314,37 @@ public class PermissionUtils { }) .setCancelable(false) .show(); - LogUtils.d(TAG, "【电池优化权限】显示手动开启提示弹窗"); + LogUtils.d(TAG, "电池优化权限:显示手动开启提示弹窗"); + } + + public void startPermissionRequest(final Activity activity) { + // 电池优化权限(通用所有机型) + if (!checkIgnoreBatteryOptimizationPermission(activity)) { + YesNoAlertDialog.show(activity, activity.getString(R.string.app_name) + "权限申请提示:", "本应用要正常使用,需要申请电池优化与自启动权限。是否进入权限设置步骤?", new YesNoAlertDialog.OnDialogResultListener(){ + @Override + public void onNo() { + ToastUtils.show(activity.getString(R.string.app_name) + "应用可能无法正常使用。"); + } + @Override + public void onYes() { + requestIgnoreBatteryOptimizationPermission(activity); + } + }); + } + } + + public void handlePermissionRequest(final Activity activity, int requestCode, int resultCode, Intent data) { + if (requestCode == PermissionUtils.REQUEST_IGNORE_BATTERY_OPTIMIZATION) { + // 自启动权限(小米专属) + // 小米机型,发起自启动权限申请 + requestAutoStartPermission(activity); + } else if (requestCode == PermissionUtils.REQUEST_AUTO_START) { + // 自启动权限(小米专属) + if (App.isDebugging() && !checkAllFileManagePermission(activity)) { + // 小米机型,发起自启动权限申请 + requestAllFileManagePermission(activity); + } + } } } diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/UriUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/UriUtils.java index 4db8dfb..8d83187 100644 --- a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/UriUtils.java +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/UriUtils.java @@ -1,10 +1,5 @@ package cc.winboll.studio.powerbell.utils; -/** - * @Author ZhanGSKen - * @Date 2024/06/28 04:23:04 - * @Describe UriUtil - */ import android.content.ContentResolver; import android.content.Context; import android.database.Cursor; @@ -19,199 +14,468 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; +/** + * Uri 工具类(Java7兼容,适配API29-30+小米机型,FileProvider安全适配) + * @Author ZhanGSKen + * @Date 2024/06/28 + */ public class UriUtils { + // ====================== 常量定义(顶部统一管理)====================== + public static final String TAG = "UriUtils"; + // FileProvider 授权后缀(与Manifest配置保持一致) + private static final String FILE_PROVIDER_SUFFIX = ".fileprovider"; + // 应用公共图片目录(API29+ 适配,替代废弃API) + private static final String APP_PUBLIC_PIC_DIR = "PowerBell/"; + // MIME类型与文件后缀映射表(覆盖常见格式,小米机型精准匹配) + private static final Map MIME_SUFFIX_MAP = new HashMap() {{ + // 图片格式(重点,含透明格式) + put("image/png", "png"); + put("image/jpeg", "jpg"); + put("image/jpg", "jpg"); + put("image/gif", "gif"); + put("image/bmp", "bmp"); + put("image/webp", "webp"); + // 音视频格式 + put("video/mp4", "mp4"); + put("video/avi", "avi"); + put("video/mkv", "mkv"); + put("audio/mp3", "mp3"); + put("audio/wav", "wav"); + // 文档格式 + put("application/pdf", "pdf"); + put("application/msword", "doc"); + put("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"); + put("application/vnd.ms-excel", "xls"); + put("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"); + }}; - public static final String TAG = "UriUtil"; - + // ====================== 新增核心方法:Uri 转文件后缀 ====================== /** - * 获取真实路径 - * - * @param context + * 【静态公共方法】根据 Uri 获取文件真实后缀(优先MIME类型匹配,适配所有Uri场景+小米机型) + * @param context 上下文(非空,用于获取ContentResolver) + * @param uri 待解析 Uri(支持 content:// / file:// 双Scheme) + * @return 小写文件后缀(如 png/jpg/mp4,无匹配返回空字符串) + */ + public static String getSuffixFromUri(Context context, Uri uri) { + LogUtils.d(TAG, "=== getSuffixFromUri 调用 start,Uri:" + (uri != null ? uri.toString() : "null") + " ==="); + // 1. 基础参数校验 + if (context == null) { + LogUtils.e(TAG, "getSuffixFromUri:Context 为空,获取失败"); + return ""; + } + if (uri == null) { + LogUtils.e(TAG, "getSuffixFromUri:Uri 为空,获取失败"); + return ""; + } + + String suffix = ""; + String scheme = uri.getScheme(); + // 2. 按 Uri Scheme 分类处理(优先精准匹配,再降级截取) + if (ContentResolver.SCHEME_CONTENT.equals(scheme)) { + // 场景1:content:// Uri(优先通过MIME类型获取,最精准) + suffix = getSuffixFromContentUri(context, uri); + LogUtils.d(TAG, "getSuffixFromUri:content:// Uri,MIME匹配后缀:" + suffix); + } else if (ContentResolver.SCHEME_FILE.equals(scheme)) { + // 场景2:file:// Uri(直接解析文件名截取后缀) + String filePath = new File(uri.getPath()).getAbsolutePath(); + suffix = getSuffixFromFilePath(filePath); + LogUtils.d(TAG, "getSuffixFromUri:file:// Uri,路径截取后缀:" + suffix); + } else { + // 场景3:未知Scheme(尝试解析Uri路径截取,兜底) + String uriPath = uri.getPath(); + suffix = uriPath != null ? getSuffixFromFilePath(uriPath) : ""; + LogUtils.w(TAG, "getSuffixFromUri:未知Scheme=" + scheme + ",兜底截取后缀:" + suffix); + } + + // 3. 最终结果处理(统一小写,去空) + suffix = suffix != null ? suffix.trim().toLowerCase() : ""; + LogUtils.d(TAG, "=== getSuffixFromUri 调用 end,最终后缀:" + suffix + " ==="); + return suffix; + } + + // ====================== 公有核心方法(对外提供能力,按功能排序)====================== + /** + * Uri 转真实文件路径(核心方法,适配 content:// / file:// 双 Scheme) + * @param context 上下文(非空) + * @param uri 待转换 Uri(非空) + * @return 真实文件绝对路径(转换失败返回 null) */ public static String getFilePathFromUri(Context context, Uri uri) { - if (uri == null) { + LogUtils.d(TAG, "=== getFilePathFromUri 调用 start ==="); + if (context == null) { + LogUtils.e(TAG, "getFilePathFromUri:Context 为空,转换失败"); return null; } - switch (uri.getScheme()) { - case ContentResolver.SCHEME_CONTENT: - //Android7.0之后的uri content:// URI - return getFilePathFromContentUri(context, uri); - case ContentResolver.SCHEME_FILE: - default: - //Android7.0之前的uri file:// - return new File(uri.getPath()).getAbsolutePath(); + if (uri == null) { + LogUtils.e(TAG, "getFilePathFromUri:Uri 为空,转换失败"); + return null; } - } - /** - * 从uri获取path - * - * @param uri content://media/external/file/109009 - *

- * FileProvider适配 - * content://com.tencent.mobileqq.fileprovider/external_files/storage/emulated/0/Tencent/QQfile_recv/ - * content://com.tencent.mm.external.fileprovider/external/tencent/MicroMsg/Download/ - */ - private static String getFilePathFromContentUri(Context context, Uri uri) { - if (null == uri) return null; - String data = null; - - String[] filePathColumn = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME}; - Cursor cursor = context.getContentResolver().query(uri, filePathColumn, null, null, null); - if (null != cursor) { - if (cursor.moveToFirst()) { - int index = cursor.getColumnIndex(MediaStore.MediaColumns.DATA); - if (index > -1) { - data = cursor.getString(index); - } else { - int nameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME); - String fileName = cursor.getString(nameIndex); - data = getPathFromInputStreamUri(context, uri, fileName); - } - } - cursor.close(); - } - return data; - } - - /** - * 用流拷贝文件一份到自己APP私有目录下 - * - * @param context - * @param uri - * @param fileName - */ - private static String getPathFromInputStreamUri(Context context, Uri uri, String fileName) { - InputStream inputStream = null; + String scheme = uri.getScheme(); String filePath = null; - - if (uri.getAuthority() != null) { - try { - inputStream = context.getContentResolver().openInputStream(uri); - File file = createTemporalFileFrom(context, inputStream, fileName); - filePath = file.getPath(); - - } catch (Exception e) { - } finally { - try { - if (inputStream != null) { - inputStream.close(); - } - } catch (Exception e) { - } - } + // 按 Uri Scheme 分类处理 + if (ContentResolver.SCHEME_CONTENT.equals(scheme)) { + LogUtils.d(TAG, "getFilePathFromUri:Scheme=content,执行ContentUri转换"); + filePath = getFilePathFromContentUri(context, uri); + } else if (ContentResolver.SCHEME_FILE.equals(scheme)) { + LogUtils.d(TAG, "getFilePathFromUri:Scheme=file,直接转换路径"); + filePath = new File(uri.getPath()).getAbsolutePath(); + } else { + LogUtils.w(TAG, "getFilePathFromUri:未知Scheme=" + scheme + ",转换失败"); } + LogUtils.d(TAG, "=== getFilePathFromUri 调用 end,结果:" + filePath + " ==="); return filePath; } - - public static Uri getUriForFile(Context context, String filePath) { - // 1. 打印传入的文件路径 - LogUtils.d(TAG, "getUriForFile -> 传入路径:" + filePath); - if (filePath == null || filePath.isEmpty()) { - LogUtils.e(TAG, "getUriForFile -> 传入路径为空"); - return null; - } - File file = new File(filePath); - // 2. 打印File对象的绝对路径和存在性 - LogUtils.d(TAG, "getUriForFile -> 文件绝对路径:" + file.getAbsolutePath()); - LogUtils.d(TAG, "getUriForFile -> 文件是否存在:" + file.exists()); - LogUtils.d(TAG, "getUriForFile -> 是否为目录:" + file.isDirectory()); + /** + * 文件路径转 Uri(核心方法,适配 Android7.0+ FileProvider,API29-30兼容) + * @param context 上下文(非空) + * @param filePath 真实文件路径(非空) + * @return 安全 Uri(转换失败返回 null) + */ + public static Uri getUriForFile(Context context, String filePath) { + LogUtils.d(TAG, "=== getUriForFile(路径版)调用 start ==="); + // 1. 基础参数校验 + if (context == null) { + LogUtils.e(TAG, "getUriForFile:Context 为空,转换失败"); + return null; + } + if (filePath == null || filePath.isEmpty()) { + LogUtils.e(TAG, "getUriForFile:文件路径为空,转换失败"); + return null; + } - // 3. 合法性校验 - if (!file.exists() || file.isDirectory()) { - LogUtils.e(TAG, "getUriForFile -> 非法路径:文件不存在或为目录"); - return null; - } + // 2. File 对象初始化与校验 + File file = new File(filePath); + LogUtils.d(TAG, "getUriForFile:文件路径=" + file.getAbsolutePath() + ",是否存在=" + file.exists()); + if (!file.exists() || file.isDirectory()) { + LogUtils.e(TAG, "getUriForFile:文件不存在或为目录,转换失败"); + return null; + } - // 4. 校验路径是否在配置的合法目录内 - String appFilesDir = context.getExternalFilesDir(null) != null ? context.getExternalFilesDir(null).getAbsolutePath() : "null"; - String publicPicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).getAbsolutePath() + "/PowerBell/"; - String internalFilesDir = context.getFilesDir().getAbsolutePath(); - String cacheDir = context.getCacheDir().getAbsolutePath(); + // 3. 合法路径校验(适配小米机型,避免FileProvider配置外路径) + if (!isPathInValidDir(context, file)) { + LogUtils.w(TAG, "getUriForFile:路径不在安全配置目录内,小米机型可能出现权限异常"); + } - String absolutePath = file.getAbsolutePath(); - boolean isInConfigDir = absolutePath.startsWith(appFilesDir) - || absolutePath.startsWith(publicPicDir) - || absolutePath.startsWith(internalFilesDir) - || absolutePath.startsWith(cacheDir); - LogUtils.d(TAG, "getUriForFile -> 路径是否在配置目录内:" + isInConfigDir); - if (!isInConfigDir) { - LogUtils.w(TAG, "getUriForFile -> 路径不在FileProvider配置范围内,可能导致异常"); - // 非强制拦截,保留原有逻辑,仅警告 - } + // 4. 调用重载方法生成 Uri + Uri uri = getUriForFile(context, file); + LogUtils.d(TAG, "=== getUriForFile(路径版)调用 end,结果:" + (uri != null ? uri.toString() : "null") + " ==="); + return uri; + } - return getUriForFile(context, file); - } + /** + * File 对象转 Uri(重载方法,直接接收File,内部安全适配) + * @param context 上下文(非空) + * @param file 待转换 File 对象(非空) + * @return 安全 Uri(转换失败返回 null) + */ + public static Uri getUriForFile(Context context, File file) { + LogUtils.d(TAG, "=== getUriForFile(File版)调用 start ==="); + // 1. 基础参数校验 + if (context == null) { + LogUtils.e(TAG, "getUriForFile:Context 为空,转换失败"); + return null; + } + if (file == null) { + LogUtils.e(TAG, "getUriForFile:File 对象为空,转换失败"); + return null; + } + LogUtils.d(TAG, "getUriForFile:文件路径=" + file.getAbsolutePath() + ",是否存在=" + file.exists()); + if (!file.exists() || file.isDirectory()) { + LogUtils.e(TAG, "getUriForFile:文件不存在或为目录,转换失败"); + return null; + } - public static Uri getUriForFile(Context context, File file) { - if (context == null) { - LogUtils.e(TAG, "getUriForFile -> Context为空"); - return null; - } - if (file == null) { - LogUtils.e(TAG, "getUriForFile -> File对象为空"); - return null; - } + // 2. 按系统版本生成 Uri(API24+ 强制 FileProvider,适配小米机型) + Uri uri = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + LogUtils.d(TAG, "getUriForFile:Android7.0+,使用FileProvider生成Uri"); + String authority = context.getPackageName() + FILE_PROVIDER_SUFFIX; + LogUtils.d(TAG, "getUriForFile:FileProvider Authority=" + authority); + try { + uri = FileProvider.getUriForFile(context, authority, file); + LogUtils.d(TAG, "getUriForFile:Content Uri生成成功=" + uri.toString()); + } catch (IllegalArgumentException e) { + LogUtils.e(TAG, "getUriForFile:FileProvider生成失败(小米机型常见原因:路径未配置/Authority不匹配)", e); + } + } else { + LogUtils.d(TAG, "getUriForFile:Android7.0以下,使用Uri.fromFile生成"); + uri = Uri.fromFile(file); + LogUtils.d(TAG, "getUriForFile:File Uri生成成功=" + uri.toString()); + } - // 1. 二次校验文件状态 - LogUtils.d(TAG, "getUriForFile(File) -> 文件路径:" + file.getAbsolutePath()); - if (!file.exists() || file.isDirectory()) { - LogUtils.e(TAG, "getUriForFile(File) -> 文件不存在或为目录"); - return null; - } + LogUtils.d(TAG, "=== getUriForFile(File版)调用 end ==="); + return uri; + } - // 2. 版本判断与Uri生成 - if (Build.VERSION.SDK_INT >= 24) { - LogUtils.d(TAG, "getUriForFile -> Android 7.0+,使用FileProvider生成Uri"); - try { - String authority = context.getPackageName() + ".fileprovider"; - LogUtils.d(TAG, "getUriForFile -> FileProvider authority:" + authority); - Uri uri = FileProvider.getUriForFile(context, authority, file); - LogUtils.d(TAG, "getUriForFile -> 生成Content Uri成功:" + uri.toString()); - return uri; - } catch (IllegalArgumentException e) { - LogUtils.e(TAG, "getUriForFile -> FileProvider生成Uri失败:路径未配置或权限不足", e); - return null; - } - } else { - LogUtils.d(TAG, "getUriForFile -> Android 7.0以下,使用Uri.fromFile生成Uri"); - Uri uri = Uri.fromFile(file); - LogUtils.d(TAG, "getUriForFile -> 生成File Uri成功:" + uri.toString()); - return uri; - } - } - - private static File createTemporalFileFrom(Context context, InputStream inputStream, String fileName) - throws IOException { + // ====================== 私有辅助方法(内部逻辑封装,不对外暴露)====================== + /** + * ContentUri 转真实路径(适配 content:// 格式,处理小米机型特殊Uri) + * @param context 上下文 + * @param uri ContentUri(如:content://media/external/file/xxx) + * @return 真实文件路径(失败返回 null) + */ + private static String getFilePathFromContentUri(Context context, Uri uri) { + LogUtils.d(TAG, "getFilePathFromContentUri:Uri=" + uri.toString()); + String filePath = null; + Cursor cursor = null; + // Java7 语法:try-catch-finally 手动关闭Cursor,避免内存泄漏 + try { + // 查询字段:优先 DATA 字段,失败则通过文件名+流拷贝获取 + String[] queryColumns = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME}; + cursor = context.getContentResolver().query(uri, queryColumns, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + // 优先读取 DATA 字段(直接获取路径) + int dataIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATA); + if (dataIndex != -1) { + filePath = cursor.getString(dataIndex); + LogUtils.d(TAG, "getFilePathFromContentUri:从DATA字段获取路径=" + filePath); + } else { + // DATA 字段为空,通过流拷贝到私有目录获取路径(小米机型特殊场景适配) + int nameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME); + String fileName = cursor.getString(nameIndex); + LogUtils.d(TAG, "getFilePathFromContentUri:DATA字段为空,通过流拷贝获取,文件名=" + fileName); + filePath = getPathFromInputStreamUri(context, uri, fileName); + } + } + } catch (Exception e) { + LogUtils.e(TAG, "getFilePathFromContentUri:查询失败", e); + } finally { + // 强制关闭Cursor,避免资源泄漏(Java7 必须手动处理) + if (cursor != null) { + try { + cursor.close(); + } catch (Exception e) { + LogUtils.e(TAG, "getFilePathFromContentUri:关闭Cursor失败", e); + } + } + } + return filePath; + } + + /** + * 流拷贝获取路径(适配无 DATA 字段的 ContentUri,小米机型特殊Uri兼容) + * 将目标文件拷贝到应用私有缓存目录,返回拷贝后的路径 + * @param context 上下文 + * @param uri ContentUri + * @param fileName 文件名 + * @return 拷贝后的文件路径(失败返回 null) + */ + private static String getPathFromInputStreamUri(Context context, Uri uri, String fileName) { + LogUtils.d(TAG, "getPathFromInputStreamUri:开始流拷贝,文件名=" + fileName); + InputStream inputStream = null; + OutputStream outputStream = null; File targetFile = null; + try { + // 1. 打开输入流(读取Uri对应文件) + inputStream = context.getContentResolver().openInputStream(uri); + if (inputStream == null) { + LogUtils.e(TAG, "getPathFromInputStreamUri:输入流打开失败"); + return null; + } + // 2. 创建目标文件(应用私有缓存目录,无权限限制) + targetFile = new File(context.getExternalCacheDir(), fileName); + // 若文件已存在,先删除(避免覆盖导致格式异常) + if (targetFile.exists()) { + boolean deleteSuccess = targetFile.delete(); + LogUtils.d(TAG, "getPathFromInputStreamUri:删除已存在文件,结果=" + deleteSuccess); + } + + // 3. 流拷贝(Java7 手动处理流,避免 try-with-resources) + outputStream = new FileOutputStream(targetFile); + byte[] buffer = new byte[8 * 1024]; // 8KB 缓冲区,平衡效率与内存 + int readLength; + while ((readLength = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, readLength); + } + outputStream.flush(); + LogUtils.d(TAG, "getPathFromInputStreamUri:流拷贝成功,路径=" + targetFile.getAbsolutePath()); + } catch (Exception e) { + LogUtils.e(TAG, "getPathFromInputStreamUri:流拷贝失败", e); + // 拷贝失败,删除临时文件 + if (targetFile != null && targetFile.exists()) { + targetFile.delete(); + } + targetFile = null; + } finally { + // 强制关闭流,避免资源泄漏(Java7 必须手动关闭) + try { + if (outputStream != null) { + outputStream.close(); + } + } catch (IOException e) { + LogUtils.e(TAG, "getPathFromInputStreamUri:关闭输出流失败", e); + } + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException e) { + LogUtils.e(TAG, "getPathFromInputStreamUri:关闭输入流失败", e); + } + } + return targetFile != null ? targetFile.getAbsolutePath() : null; + } + + /** + * 校验路径是否在安全目录内(适配API29-30+小米机型,避免FileProvider权限异常) + * 仅允许:应用私有目录、缓存目录、应用专属公共目录 + * @param context 上下文 + * @param file 待校验文件 + * @return true=安全路径,false=非安全路径 + */ + private static boolean isPathInValidDir(Context context, File file) { + String absolutePath = file.getAbsolutePath(); + // 1. 应用外部私有目录(API29+ 推荐,无权限限制) + String externalPrivateDir = context.getExternalFilesDir(null) != null + ? context.getExternalFilesDir(null).getAbsolutePath() + : ""; + // 2. 应用内部私有目录(无权限限制) + String internalPrivateDir = context.getFilesDir().getAbsolutePath(); + // 3. 应用缓存目录(无权限限制) + String cacheDir = context.getCacheDir().getAbsolutePath(); + // 4. 应用专属公共目录(API29+ 适配,替代废弃的 getExternalStoragePublicDirectory) + String appPublicDir = Environment.getExternalStorageDirectory().getAbsolutePath() + + File.separator + Environment.DIRECTORY_PICTURES + + File.separator + APP_PUBLIC_PIC_DIR; + + // 校验路径是否在安全目录内(小米机型必须严格校验,否则FileProvider会抛异常) + boolean isInValidDir = absolutePath.startsWith(externalPrivateDir) + || absolutePath.startsWith(internalPrivateDir) + || absolutePath.startsWith(cacheDir) + || absolutePath.startsWith(appPublicDir); + + LogUtils.d(TAG, "isPathInValidDir:外部私有目录=" + externalPrivateDir + + ",公共目录=" + appPublicDir + + ",校验结果=" + isInValidDir); + return isInValidDir; + } + + /** + * 流拷贝创建临时文件(内部辅助,封装拷贝逻辑) + * @param context 上下文 + * @param inputStream 输入流 + * @param fileName 文件名 + * @return 临时文件(失败返回 null) + * @throws IOException 流操作异常 + */ + private static File createTemporalFileFrom(Context context, InputStream inputStream, String fileName) throws IOException { + File targetFile = null; if (inputStream != null) { - int read; byte[] buffer = new byte[8 * 1024]; - //自己定义拷贝文件路径 + int readLength; targetFile = new File(context.getExternalCacheDir(), fileName); if (targetFile.exists()) { targetFile.delete(); } OutputStream outputStream = new FileOutputStream(targetFile); - - while ((read = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, read); + while ((readLength = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, readLength); } outputStream.flush(); - - try { - outputStream.close(); - } catch (IOException e) { - e.printStackTrace(); - } + outputStream.close(); } - return targetFile; } + /** + * 辅助:ContentUri 通过 MIME 类型获取后缀(精准匹配,不受文件名伪造影响) + * @param context 上下文 + * @param uri ContentUri + * @return 匹配的后缀(无匹配返回空字符串) + */ + private static String getSuffixFromContentUri(Context context, Uri uri) { + String mime = null; + try { + // 通过 ContentResolver 获取 Uri 对应的 MIME 类型(系统级匹配,最精准) + mime = context.getContentResolver().getType(uri); + LogUtils.d(TAG, "getSuffixFromContentUri:获取MIME类型=" + mime); + if (mime == null || mime.isEmpty()) { + // MIME 为空,尝试解析文件名兜底 + String fileName = getFileNameFromContentUri(context, uri); + return getSuffixFromFilePath(fileName); + } + // MIME 类型匹配后缀(优先完全匹配,再模糊匹配) + if (MIME_SUFFIX_MAP.containsKey(mime)) { + return MIME_SUFFIX_MAP.get(mime); + } + // 模糊匹配(如 image/* 匹配通用图片后缀,默认png) + if (mime.startsWith("image/")) { + return "png"; + } else if (mime.startsWith("video/")) { + return "mp4"; + } else if (mime.startsWith("audio/")) { + return "mp3"; + } else if (mime.startsWith("application/")) { + return "pdf"; + } + } catch (Exception e) { + LogUtils.e(TAG, "getSuffixFromContentUri:MIME解析失败,mime=" + mime, e); + } + // 所有方式失败,解析Uri路径兜底 + return getSuffixFromFilePath(uri.getPath()); + } + /** + * 辅助:从 ContentUri 获取文件名(MIME 解析失败时兜底) + * @param context 上下文 + * @param uri ContentUri + * @return 文件名(失败返回空字符串) + */ + private static String getFileNameFromContentUri(Context context, Uri uri) { + Cursor cursor = null; + try { + String[] queryColumns = {MediaStore.MediaColumns.DISPLAY_NAME}; + cursor = context.getContentResolver().query(uri, queryColumns, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME); + return cursor.getString(nameIndex); + } + } catch (Exception e) { + LogUtils.e(TAG, "getFileNameFromContentUri:查询失败", e); + } finally { + if (cursor != null) { + try { + cursor.close(); + } catch (Exception e) { + LogUtils.e(TAG, "getFileNameFromContentUri:关闭Cursor失败", e); + } + } + } + return ""; + } + + /** + * 辅助:从文件路径/文件名截取后缀(兜底方案,处理各种路径格式) + * @param path 文件路径/文件名 + * @return 截取的后缀(无后缀返回空字符串) + */ + private static String getSuffixFromFilePath(String path) { + if (path == null || path.isEmpty()) { + return ""; + } + // 处理路径中的分隔符(兼容 Windows/Android 路径格式) + path = path.replace("\\", "/"); + // 取最后一个 "/" 后的文件名(避免路径包含 "." 导致误判) + int lastSepIndex = path.lastIndexOf("/"); + if (lastSepIndex != -1 && lastSepIndex < path.length() - 1) { + path = path.substring(lastSepIndex + 1); + } + // 截取最后一个 "." 后的后缀(过滤无后缀/点开头/点结尾场景) + int lastDotIndex = path.lastIndexOf("."); + if (lastDotIndex == -1 || lastDotIndex == 0 || lastDotIndex == path.length() - 1) { + return ""; + } + // 过滤后缀中的非法字符(仅保留字母/数字,避免特殊字符干扰) + String suffix = path.substring(lastDotIndex + 1).replaceAll("[^a-zA-Z0-9]", ""); + // 限制后缀长度(1-5位,避免超长伪造后缀) + return suffix.length() >= 1 && suffix.length() <= 5 ? suffix : ""; + } } +