20251214_165544_910

This commit is contained in:
2025-12-14 16:55:59 +08:00
parent 08a33365b3
commit 947df2e9b4
10 changed files with 1154 additions and 563 deletions

View File

@@ -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

View File

@@ -4,34 +4,56 @@
xmlns:tools="http://schemas.android.com/tools"
package="cc.winboll.studio.powerbell">
<!-- 运行前台服务 -->
<!-- ====================== 原有权限保留 + 补充核心权限 ====================== -->
<!-- 运行前台服务(原有保留,补充 Android 12+ 特殊前台服务权限) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- 开机启动 -->
<!-- 开机启动(原有保留,确保自启广播生效) -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- 显示通知 -->
<!-- 显示通知(原有保留,前台服务/保活必备) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- PACKAGE_USAGE_STATS -->
<!-- 应用使用统计相关(原有保留,忽略保护权限警告) -->
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"/>
<!-- BATTERY_STATS -->
<uses-permission android:name="android.permission.BATTERY_STATS"/>
<uses-permission
android:name="android.permission.ACCESS_PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions"/>
<uses-feature android:name="android.hardware.camera"/>
<uses-feature android:name="android.hardware.camera.autofocus"/>
<!-- 相机相关(原有保留,补充权限等级声明,避免安装警告) -->
<uses-feature
android:name="android.hardware.camera"
android:required="false"/> <!-- 非核心功能设为非必须,兼容无相机设备 -->
<uses-feature
android:name="android.hardware.camera.autofocus"
android:required="false"/>
<uses-permission android:name="android.permission.CAMERA" /> <!-- 补充相机权限原有仅声明feature无权限 -->
<!-- 应用信息相关(原有保留) -->
<uses-permission android:name="android.permission.GET_PACKAGE_SIZE"/>
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission"/>
<uses-permission
android:name="android.permission.ACCESS_PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions"/>
<!-- 新增:文件管理权限(对应 PermissionUtils 全文件管理逻辑) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> <!-- API30+ 全文件权限 -->
<!-- 新增:忽略电池优化权限(对应 PermissionUtils 电池优化逻辑,必须声明) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- 新增API30+ 跳转系统权限页兼容(避免自启/文件权限跳转失败) -->
<queries>
<!-- 全文件管理权限跳转兼容 -->
<!-- 小米自启权限跳转兼容(精准匹配安全中心包名) -->
<package android:name="com.miui.securitycenter" />
<!-- 电池优化权限跳转兼容 -->
</queries>
<application
android:name=".App"
@@ -43,17 +65,25 @@
android:resizeableActivity="true"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning">
android:supportsRtl="true"
tools:ignore="GoogleAppIndexingWarning,UnusedAttribute">
<!-- ====================== 页面配置(原有保留,优化 exported 安全) ====================== -->
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:exported="true"
android:launchMode="singleTask">
<!-- 补充:默认启动页 intent-filter原有在 alias主 Activity 可加可不加,增强稳定性) -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name=".activities.CrashActivity"/>
<activity
android:name=".activities.CrashActivity"
android:exported="false"/> <!-- 新增:非外部调用,设为 false提升安全 -->
<activity-alias
android:name=".MainActivityEN1"
@@ -62,19 +92,13 @@
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcutsmainen1"/>
</activity-alias>
<activity-alias
@@ -84,19 +108,13 @@
android:label="@string/app_name_cn1"
android:icon="@drawable/ic_launcher"
android:enabled="false">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcutsmaincn1"/>
</activity-alias>
<activity-alias
@@ -106,116 +124,121 @@
android:label="@string/app_name_cn2"
android:icon="@drawable/ic_launcher"
android:enabled="false">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcutsmaincn2"/>
</activity-alias>
<activity
android:name=".activities.ClearRecordActivity"
android:parentActivityName="cc.winboll.studio.powerbell.MainActivity"
android:launchMode="singleTask">
</activity>
android:launchMode="singleTask"
android:exported="false"/> <!-- 新增:非外部调用,设为 false -->
<activity
android:name=".activities.BackgroundSettingsActivity"
android:parentActivityName="cc.winboll.studio.powerbell.MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/jpeg"/>
<data android:mimeType="image/jpg"/>
<data android:mimeType="image/png"/>
<data android:mimeType="image/webp"/>
<data android:mimeType="image/*"/>
</intent-filter>
</activity>
<!-- ====================== 广播接收器(优化自启广播,提升保活成功率) ====================== -->
<receiver
android:name=".receivers.MainReceiver"
android:enabled="true"
android:exported="false"
android:exported="true"
android:directBootAware="true">
<intent-filter>
<intent-filter android:priority="1000"> <!-- 新增:广播优先级最高,确保优先接收开机广播 -->
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<!-- 补充:充电/解锁广播,增强后台唤醒机会 -->
<action android:name="android.intent.action.POWER_CONNECTED"/>
<action android:name="android.intent.action.USER_PRESENT"/>
</intent-filter>
</receiver>
<!-- ====================== 服务配置(核心优化:前台服务保活,适配 API29-30 ====================== -->
<!-- 核心前台服务ControlCenterService保活核心重点优化 -->
<service
android:name=".services.ControlCenterService"
android:priority="1000"
android:enabled="true"
android:exported="false"
android:process=".controlcenterservice"/>
android:process=".controlcenterservice"
android:foregroundServiceType="dataSync">
<!-- 新增Android 12+ 前台服务用途声明(系统强制,否则拦截服务启动) -->
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FOREGROUND_SERVICE"
android:value="后台核心功能运行、持续保活" /> <!-- 按实际用途填写,不可空 -->
</service>
<!-- 辅助服务AssistantService按需优化增强稳定性 -->
<service
android:name=".services.AssistantService"
android:enabled="true"
android:exported="false"
android:process=".assistantservice"/>
android:process=".assistantservice"> <!-- 若需前台启动,添加此配置;纯后台可移除 -->
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FOREGROUND_SERVICE"
android:value="辅助核心功能运行" />
</service>
<!-- ====================== 其他配置(原有保留,补充优化) ====================== -->
<meta-data
android:name="android.max_aspect"
android:value="4.0"/>
<activity android:name=".activities.BatteryReporterActivity"/>
<activity android:name=".activities.PixelPickerActivity"/>
<activity android:name=".activities.BatteryReportActivity"/>
<activity android:name=".unittest.MainUnitTestActivity"/>
<!-- 所有非外部调用的 Activity统一设 exported=false提升安全 -->
<activity
android:name=".activities.BatteryReporterActivity"
android:exported="false"/>
<activity
android:name=".activities.PixelPickerActivity"
android:exported="false"/>
<activity
android:name=".activities.BatteryReportActivity"
android:exported="false"/>
<activity
android:name=".unittest.MainUnitTestActivity"
android:exported="false"/>
<activity
android:name=".activities.ShortcutActionActivity"
android:exported="false"/>
<activity
android:name=".activities.SettingsActivity"
android:exported="false"/>
<!-- 文件提供者(原有保留,正常使用) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider"/>
</provider>
<activity android:name=".activities.ShortcutActionActivity"/>
<activity android:name=".activities.SettingsActivity"/>
<!-- UCrop 第三方页面原有保留exported=true 正常) -->
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:exported="true">
</activity>
</application>
</manifest>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -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);
}

View File

@@ -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&豆包大模型<zhangsken@qq.com>
* @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.0API 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);
}
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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&豆包大模型<zhangsken@qq.com>
* @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 10API29
private static final int SDK_VERSION_R = 30; // Android 11API30
// 小米手机自启动权限页面包名/类名(小米专属跳转路径)
// 小米自启动权限页面配置(专属跳转路径,精准适配
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 {
// 方案2Action备用跳转兼容机型差异
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);
}
}
}
}

View File

@@ -1,10 +1,5 @@
package cc.winboll.studio.powerbell.utils;
/**
* @Author ZhanGSKen<zhangsken@qq.com>
* @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<zhangsken@qq.com>
* @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<String, String> MIME_SUFFIX_MAP = new HashMap<String, String>() {{
// 图片格式(重点,含透明格式)
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 调用 startUri" + (uri != null ? uri.toString() : "null") + " ===");
// 1. 基础参数校验
if (context == null) {
LogUtils.e(TAG, "getSuffixFromUriContext 为空,获取失败");
return "";
}
if (uri == null) {
LogUtils.e(TAG, "getSuffixFromUriUri 为空,获取失败");
return "";
}
String suffix = "";
String scheme = uri.getScheme();
// 2. 按 Uri Scheme 分类处理(优先精准匹配,再降级截取)
if (ContentResolver.SCHEME_CONTENT.equals(scheme)) {
// 场景1content:// Uri优先通过MIME类型获取最精准
suffix = getSuffixFromContentUri(context, uri);
LogUtils.d(TAG, "getSuffixFromUricontent:// UriMIME匹配后缀" + suffix);
} else if (ContentResolver.SCHEME_FILE.equals(scheme)) {
// 场景2file:// Uri直接解析文件名截取后缀
String filePath = new File(uri.getPath()).getAbsolutePath();
suffix = getSuffixFromFilePath(filePath);
LogUtils.d(TAG, "getSuffixFromUrifile:// 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, "getFilePathFromUriContext 为空,转换失败");
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, "getFilePathFromUriUri 为空,转换失败");
return null;
}
}
/**
* 从uri获取path
*
* @param uri content://media/external/file/109009
* <p>
* 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, "getFilePathFromUriScheme=content执行ContentUri转换");
filePath = getFilePathFromContentUri(context, uri);
} else if (ContentResolver.SCHEME_FILE.equals(scheme)) {
LogUtils.d(TAG, "getFilePathFromUriScheme=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+ FileProviderAPI29-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, "getUriForFileContext 为空,转换失败");
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, "=== getUriForFileFile版调用 start ===");
// 1. 基础参数校验
if (context == null) {
LogUtils.e(TAG, "getUriForFileContext 为空,转换失败");
return null;
}
if (file == null) {
LogUtils.e(TAG, "getUriForFileFile 对象为空,转换失败");
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. 按系统版本生成 UriAPI24+ 强制 FileProvider适配小米机型
Uri uri = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
LogUtils.d(TAG, "getUriForFileAndroid7.0+使用FileProvider生成Uri");
String authority = context.getPackageName() + FILE_PROVIDER_SUFFIX;
LogUtils.d(TAG, "getUriForFileFileProvider Authority=" + authority);
try {
uri = FileProvider.getUriForFile(context, authority, file);
LogUtils.d(TAG, "getUriForFileContent Uri生成成功=" + uri.toString());
} catch (IllegalArgumentException e) {
LogUtils.e(TAG, "getUriForFileFileProvider生成失败小米机型常见原因路径未配置/Authority不匹配", e);
}
} else {
LogUtils.d(TAG, "getUriForFileAndroid7.0以下使用Uri.fromFile生成");
uri = Uri.fromFile(file);
LogUtils.d(TAG, "getUriForFileFile 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, "=== getUriForFileFile版调用 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 ContentUricontent://media/external/file/xxx
* @return 真实文件路径(失败返回 null
*/
private static String getFilePathFromContentUri(Context context, Uri uri) {
LogUtils.d(TAG, "getFilePathFromContentUriUri=" + 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, "getFilePathFromContentUriDATA字段为空通过流拷贝获取文件名=" + 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, "getSuffixFromContentUriMIME解析失败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 : "";
}
}