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 #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 stageCount=3
libraryProject= libraryProject=
baseVersion=15.14 baseVersion=15.14
publishVersion=15.14.2 publishVersion=15.14.2
buildCount=0 buildCount=42
baseBetaVersion=15.14.3 baseBetaVersion=15.14.3

View File

@@ -4,34 +4,56 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="cc.winboll.studio.powerbell"> package="cc.winboll.studio.powerbell">
<!-- 运行前台服务 --> <!-- ====================== 原有权限保留 + 补充核心权限 ====================== -->
<!-- 运行前台服务(原有保留,补充 Android 12+ 特殊前台服务权限) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <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.RECEIVE_BOOT_COMPLETED"/>
<!-- 显示通知 --> <!-- 显示通知(原有保留,前台服务/保活必备) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- PACKAGE_USAGE_STATS --> <!-- 应用使用统计相关(原有保留,忽略保护权限警告) -->
<uses-permission android:name="android.permission.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.BATTERY_STATS"/>
<uses-permission
android:name="android.permission.ACCESS_PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions"/>
<uses-feature android:name="android.hardware.camera"/> <!-- 相机相关(原有保留,补充权限等级声明,避免安装警告) -->
<uses-feature
<uses-feature android:name="android.hardware.camera.autofocus"/> 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.GET_PACKAGE_SIZE"/>
<uses-permission <uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES" android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission"/> tools:ignore="QueryAllPackagesPermission"/>
<uses-permission <!-- 新增:文件管理权限(对应 PermissionUtils 全文件管理逻辑) -->
android:name="android.permission.ACCESS_PACKAGE_USAGE_STATS" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
tools:ignore="ProtectedPermissions"/> <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 <application
android:name=".App" android:name=".App"
@@ -43,17 +65,25 @@
android:resizeableActivity="true" android:resizeableActivity="true"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning"> android:supportsRtl="true"
tools:ignore="GoogleAppIndexingWarning,UnusedAttribute">
<!-- ====================== 页面配置(原有保留,优化 exported 安全) ====================== -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:label="@string/app_name" android:label="@string/app_name"
android:exported="true" android:exported="true"
android:launchMode="singleTask"> 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>
<activity android:name=".activities.CrashActivity"/> <activity
android:name=".activities.CrashActivity"
android:exported="false"/> <!-- 新增:非外部调用,设为 false提升安全 -->
<activity-alias <activity-alias
android:name=".MainActivityEN1" android:name=".MainActivityEN1"
@@ -62,19 +92,13 @@
android:label="@string/app_name" android:label="@string/app_name"
android:icon="@drawable/ic_launcher" android:icon="@drawable/ic_launcher"
android:enabled="true"> android:enabled="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
android:resource="@xml/shortcutsmainen1"/> android:resource="@xml/shortcutsmainen1"/>
</activity-alias> </activity-alias>
<activity-alias <activity-alias
@@ -84,19 +108,13 @@
android:label="@string/app_name_cn1" android:label="@string/app_name_cn1"
android:icon="@drawable/ic_launcher" android:icon="@drawable/ic_launcher"
android:enabled="false"> android:enabled="false">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
android:resource="@xml/shortcutsmaincn1"/> android:resource="@xml/shortcutsmaincn1"/>
</activity-alias> </activity-alias>
<activity-alias <activity-alias
@@ -106,116 +124,121 @@
android:label="@string/app_name_cn2" android:label="@string/app_name_cn2"
android:icon="@drawable/ic_launcher" android:icon="@drawable/ic_launcher"
android:enabled="false"> android:enabled="false">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
android:resource="@xml/shortcutsmaincn2"/> android:resource="@xml/shortcutsmaincn2"/>
</activity-alias> </activity-alias>
<activity <activity
android:name=".activities.ClearRecordActivity" android:name=".activities.ClearRecordActivity"
android:parentActivityName="cc.winboll.studio.powerbell.MainActivity" android:parentActivityName="cc.winboll.studio.powerbell.MainActivity"
android:launchMode="singleTask"> android:launchMode="singleTask"
android:exported="false"/> <!-- 新增:非外部调用,设为 false -->
</activity>
<activity <activity
android:name=".activities.BackgroundSettingsActivity" android:name=".activities.BackgroundSettingsActivity"
android:parentActivityName="cc.winboll.studio.powerbell.MainActivity" android:parentActivityName="cc.winboll.studio.powerbell.MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask"> android:launchMode="singleTask">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND"/> <action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/jpeg"/> <data android:mimeType="image/jpeg"/>
<data android:mimeType="image/jpg"/> <data android:mimeType="image/jpg"/>
<data android:mimeType="image/png"/> <data android:mimeType="image/png"/>
<data android:mimeType="image/webp"/> <data android:mimeType="image/webp"/>
<data android:mimeType="image/*"/> <data android:mimeType="image/*"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- ====================== 广播接收器(优化自启广播,提升保活成功率) ====================== -->
<receiver <receiver
android:name=".receivers.MainReceiver" android:name=".receivers.MainReceiver"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="true"
android:directBootAware="true"> android:directBootAware="true">
<intent-filter android:priority="1000"> <!-- 新增:广播优先级最高,确保优先接收开机广播 -->
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/> <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> </intent-filter>
</receiver> </receiver>
<!-- ====================== 服务配置(核心优化:前台服务保活,适配 API29-30 ====================== -->
<!-- 核心前台服务ControlCenterService保活核心重点优化 -->
<service <service
android:name=".services.ControlCenterService" android:name=".services.ControlCenterService"
android:priority="1000" android:priority="1000"
android:enabled="true" android:enabled="true"
android:exported="false" 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 <service
android:name=".services.AssistantService" android:name=".services.AssistantService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:process=".assistantservice"/> android:process=".assistantservice"> <!-- 若需前台启动,添加此配置;纯后台可移除 -->
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FOREGROUND_SERVICE"
android:value="辅助核心功能运行" />
</service>
<!-- ====================== 其他配置(原有保留,补充优化) ====================== -->
<meta-data <meta-data
android:name="android.max_aspect" android:name="android.max_aspect"
android:value="4.0"/> android:value="4.0"/>
<activity android:name=".activities.BatteryReporterActivity"/> <!-- 所有非外部调用的 Activity统一设 exported=false提升安全 -->
<activity
<activity android:name=".activities.PixelPickerActivity"/> android:name=".activities.BatteryReporterActivity"
android:exported="false"/>
<activity android:name=".activities.BatteryReportActivity"/> <activity
android:name=".activities.PixelPickerActivity"
<activity android:name=".unittest.MainUnitTestActivity"/> 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 <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
android:exported="false" android:exported="false"
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data <meta-data
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider"/> android:resource="@xml/file_provider"/>
</provider> </provider>
<activity android:name=".activities.ShortcutActionActivity"/> <!-- UCrop 第三方页面原有保留exported=true 正常) -->
<activity android:name=".activities.SettingsActivity"/>
<activity <activity
android:name="com.yalantis.ucrop.UCropActivity" android:name="com.yalantis.ucrop.UCropActivity"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:exported="true"> android:exported="true">
</activity> </activity>
</application> </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 @Override
protected void onPostCreate(Bundle savedInstanceState) { protected void onPostCreate(Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState); super.onPostCreate(savedInstanceState);
permissionUtils.startPermissionRequest(this);
// 电池优化权限(通用所有机型)
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);
}
});
}
} }
@Override @Override
@@ -226,13 +213,9 @@ public class MainActivity extends WinBoLLActivity {
protected void onActivityResult(int requestCode, int resultCode, Intent data) { protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data); super.onActivityResult(requestCode, resultCode, data);
if (requestCode == PermissionUtils.REQUEST_IGNORE_BATTERY_OPTIMIZATION) { permissionUtils.handlePermissionRequest(this, requestCode, resultCode, data);
// 自启动权限(小米专属)
if (permissionUtils.checkAutoStartPermission(this)) { if (requestCode == REQUEST_READ_MEDIA_IMAGES) {
// 小米机型,发起自启动权限申请
permissionUtils.requestAutoStartPermission(this);
}
} else if (requestCode == REQUEST_READ_MEDIA_IMAGES) {
if (_mHandler != null) { if (_mHandler != null) {
_mHandler.sendEmptyMessage(MSG_LOAD_BACKGROUND); _mHandler.sendEmptyMessage(MSG_LOAD_BACKGROUND);
} }

View File

@@ -1,183 +1,249 @@
package cc.winboll.studio.powerbell.unittest; package cc.winboll.studio.powerbell.unittest;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.view.View; import android.view.View;
import android.widget.Button; import android.widget.Button;
import androidx.appcompat.app.AppCompatActivity; 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.LogUtils;
import cc.winboll.studio.libappbase.ToastUtils; import cc.winboll.studio.libappbase.ToastUtils;
import cc.winboll.studio.powerbell.MainActivity; import cc.winboll.studio.powerbell.MainActivity;
import cc.winboll.studio.powerbell.R; import cc.winboll.studio.powerbell.R;
import cc.winboll.studio.powerbell.utils.BackgroundSourceUtils; 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.utils.ImageCropUtils;
import cc.winboll.studio.powerbell.views.BackgroundView; import cc.winboll.studio.powerbell.views.BackgroundView;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import cc.winboll.studio.powerbell.models.BackgroundBean;
/** /**
* @Author ZhanGSKen&豆包大模型<zhangsken@qq.com> * 终极修复版放弃FileProvider直接用私有目录File路径彻底解决UID冲突
* @Date 2025/11/19 18:04
* @Describe 单元测试启动主页窗口
*/ */
public class MainUnitTestActivity extends AppCompatActivity { public class MainUnitTestActivity extends AppCompatActivity {
// ====================== 常量定义 ======================
public static final String TAG = "MainUnitTestActivity"; public static final String TAG = "MainUnitTestActivity";
public static final int REQUEST_CROP_IMAGE = 0; public static final int REQUEST_CROP_IMAGE = 0;
// 新增:权限请求码 private static final String ASSETS_TEST_IMAGE_PATH = "unittest/unittest.png";
public static final int REQUEST_STORAGE_PERMISSION = 1001;
View mainView; // ====================== 成员变量移除所有Uri相关 ======================
BackgroundSourceUtils mBgSourceUtils; private BackgroundView mBackgroundView;
BackgroundView mBackgroundView; private String mAppPrivateDirPath;
// 测试图片路径用Environment获取适配低版本避免硬编码 private File mPrivateTestImageFile; // 仅用File不用Uri
String szTestSource = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) private File mPrivateCropImageFile;
+ "/PowerBell/unittest/1764946782079.jpeg"; BackgroundBean mPreviewBackgroundBean;
// ====================== 生命周期方法 ======================
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
LogUtils.d(TAG, "=== 页面 onCreate 启动 ===");
mBgSourceUtils = BackgroundSourceUtils.getInstance(this);
mBgSourceUtils.loadSettings();
setContentView(R.layout.activity_mainunittest);
mBackgroundView = findViewById(R.id.backgroundview);
((Button)findViewById(R.id.btn_main_activity)).setOnClickListener(new View.OnClickListener(){ initBaseParams();
@Override initViewAndEvent();
public void onClick(View v) { copyAssetsTestImageToPrivateDir();
startActivity(new Intent(MainUnitTestActivity.this, MainActivity.class)); //loadBackgroundByFile(); // 直接用File加载
} mPreviewBackgroundBean = new BackgroundBean();
}); mPreviewBackgroundBean.setBackgroundFileName(mPrivateTestImageFile.getName());
mPreviewBackgroundBean.setBackgroundFilePath(mPrivateTestImageFile.getAbsolutePath());
// 裁剪测试按钮点击事件(新增权限校验) mPreviewBackgroundBean.setBackgroundScaledCompressFileName(mPrivateCropImageFile.getName());
((Button)findViewById(R.id.btn_test_cropimage)).setOnClickListener(new View.OnClickListener(){ mPreviewBackgroundBean.setBackgroundScaledCompressFilePath(mPrivateCropImageFile.getAbsolutePath());
@Override mPreviewBackgroundBean.setIsUseBackgroundFile(true);
public void onClick(View v) { doubleRefreshPreview();
ToastUtils.show("onClick准备启动裁剪");
LogUtils.d(TAG, "【裁剪测试】点击裁剪按钮,校验权限");
// 修复1移除高版本API依赖适配低版本存储权限校验
if (checkStoragePermission()) {
// 权限已授予,启动裁剪
startCropTest();
} else {
// 权限未授予,申请权限
requestStoragePermission();
}
}
});
ToastUtils.show(String.format("%s onCreate", TAG)); ToastUtils.show("单元测试页面启动完成");
// 加载测试图片(验证图片路径是否有效) LogUtils.d(TAG, "=== 页面 onCreate 初始化结束 ===");
loadBackground();
} }
/**
* 启动裁剪测试(抽取为单独方法,便于权限回调后调用)
*/
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 @Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults); super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_STORAGE_PERMISSION) { LogUtils.d(TAG, "=== onActivityResult 回调 ===");
// 校验权限是否授予 if (requestCode == REQUEST_CROP_IMAGE) {
boolean allGranted = true; handleCropResult(resultCode);
for (int result : grantResults) { }
if (result != PackageManager.PERMISSION_GRANTED) { }
allGranted = false;
break; // ====================== 初始化相关方法 ======================
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 // ====================== 核心业务方法全改为File路径 ======================
protected void onActivityResult(int requestCode, int resultCode, Intent data) { /** 直接用File路径加载背景图无Uri无冲突 */
super.onActivityResult(requestCode, resultCode, data); // private void loadBackgroundByFile() {
LogUtils.d(TAG, "【裁剪回调】requestCode" + requestCode + "resultCode" + resultCode + "data" + (data == null ? "null" : data.toString())); // LogUtils.d(TAG, "开始加载背景图File路径版");
ToastUtils.show(String.format("requestCode %d, resultCode %d, data is %s",requestCode, resultCode, data == null)); // if (mPrivateTestImageFile.exists() && mPrivateTestImageFile.length() > 100) {
// 裁剪完成后回收权限 // mBackgroundView.loadImage(mPrivateTestImageFile.getAbsolutePath()); // 直接传路径
if (requestCode == REQUEST_CROP_IMAGE) { // LogUtils.d(TAG, "背景图加载成功:" + mPrivateTestImageFile.getAbsolutePath());
String dstOutputPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) // ToastUtils.show("背景图加载成功");
+ "/PowerBell/unittest/1764946782079-crop.jpeg"; // } else {
//Uri outputUri = ImageCropUtils.getFileProviderUriPublic(this, new File(dstOutputPath)); // LogUtils.e(TAG, "背景图加载失败:文件无效");
//ImageCropUtils.releaseCropPermission(this, outputUri); // ToastUtils.show("背景图加载失败");
mBackgroundView.loadImage(dstOutputPath); // }
// }
/** 直接用File启动裁剪关键调用ImageCropUtils的File重载方法 */
private void startCropTestByFile() {
LogUtils.d(TAG, "启动裁剪File路径版原图" + mPrivateTestImageFile.getAbsolutePath());
// 确保输出目录存在
File cropParent = mPrivateCropImageFile.getParentFile();
if (!cropParent.exists()) {
cropParent.mkdirs();
} }
}
// 调用ImageCropUtils的File参数方法核心绕开Uri
void loadBackground() { ImageCropUtils.startImageCrop(
// 校验测试图片是否存在(避免路径错误) this,
File testFile = new File(szTestSource); mPrivateTestImageFile, // 原图File
if (testFile.exists() && testFile.length() > 100) { mPrivateCropImageFile, // 输出File
mBackgroundView.loadImage(szTestSource); 0,
LogUtils.d(TAG, "【图片加载】测试图片加载成功:" + szTestSource); 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 { } else {
ToastUtils.show("测试图片不存在或无效"); LogUtils.e(TAG, "裁剪失败resultCode异常");
LogUtils.e(TAG, "【图片加载】测试图片无效:" + szTestSource); 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文件存在无需创建空白背景"); LogUtils.d(TAG, "背景Bean文件存在无需创建空白背景");
return false; return false;
} }
String genNewCropFileName() { String genNewCropFileName() {
return UUID.randomUUID().toString() + System.currentTimeMillis(); return UUID.randomUUID().toString() + System.currentTimeMillis();
} }
@@ -231,10 +231,12 @@ public class BackgroundSourceUtils {
} }
Uri uri = UriUtils.getUriForFile(mContext, oldPreviewBackgroundBean.getBackgroundFilePath()); 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(); String newCropFileName = genNewCropFileName();
mCropSourceFile = new File(fCropCacheDir, newCropFileName + "." + fileSuffix); mCropSourceFile = new File(fCropCacheDir, newCropFileName + File.separator + fileSuffix);
mCropResultFile = new File(fCropCacheDir, "SelectCompress_" + newCropFileName + "." + fileSuffix); mCropResultFile = new File(fCropCacheDir, "SelectCompress_" + newCropFileName + ".png");
if (FileUtils.isFileExists(oldPreviewBackgroundBean.getBackgroundScaledCompressFilePath())) { if (FileUtils.isFileExists(oldPreviewBackgroundBean.getBackgroundScaledCompressFilePath())) {
FileUtils.copyFile(new File(oldPreviewBackgroundBean.getBackgroundScaledCompressFilePath()), mCropResultFile); 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) { public static boolean isFileExists(String path) {
File file = new File(path); File file = new File(path);
return file.exists(); 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 android.os.Build;
import androidx.core.content.FileProvider; import androidx.core.content.FileProvider;
import cc.winboll.studio.libappbase.LogUtils; import cc.winboll.studio.libappbase.LogUtils;
import cc.winboll.studio.powerbell.R;
import cc.winboll.studio.powerbell.models.BackgroundBean; import cc.winboll.studio.powerbell.models.BackgroundBean;
import com.yalantis.ucrop.UCrop; import com.yalantis.ucrop.UCrop;
import com.yalantis.ucrop.UCropActivity;
import java.io.File; import java.io.File;
import cc.winboll.studio.powerbell.R;
/** /**
* 图片裁剪工具类集成uCrop,脱离系统依赖 * 图片裁剪工具类(集成 uCrop 2.2.8 终极兼容版,强制输出 PNG 格式,全程保留透明通道,支持 Uri/File 双传参
*/ */
public class ImageCropUtils { public class ImageCropUtils {
public static final String TAG = "ImageCropUtils"; public static final String TAG = "ImageCropUtils";
// FileProvider 授权(与项目一致) // FileProvider 授权(与 AndroidManifest 配置一致)
private static final String FILE_PROVIDER_SUFFIX = ".fileprovider"; 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 activity 上下文
* @param inputFile 输入图片文件 * @param inputFile 输入图片文件(任意格式)
* @param outputFile 输出图片文件 * @param outputFile 输出图片文件(最终强制转为 PNG
* @param isFreeCrop 是否自由裁剪true=自由false=固定比例 * @param aspectX 固定比例 X自由裁剪传 0
* @param aspectY 固定比例 Y自由裁剪传 0
* @param isFreeCrop 是否自由裁剪
* @param requestCode 裁剪请求码 * @param requestCode 裁剪请求码
*/ */
public static void startImageCrop(Activity activity, public static void startImageCrop(Activity activity,
File inputFile, File inputFile,
File outputFile, File outputFile,
int aspectX, int aspectX,
int aspectY, int aspectY,
boolean isFreeCrop, boolean isFreeCrop,
int requestCode) { int requestCode) {
// 校验输入参数 // 1. 输入参数校验
if (activity == null || activity.isFinishing()) { if (activity == null || activity.isFinishing()) {
LogUtils.e(TAG, "【裁剪异常】上下文Activity无效"); LogUtils.e(TAG, "【裁剪异常】Activity 无效或已销毁");
return; return;
} }
if (inputFile == null || !inputFile.exists() || inputFile.length() <= 100) { if (inputFile == null || !inputFile.exists() || inputFile.length() <= 100) {
LogUtils.e(TAG, "【裁剪异常】输入文件无效"); LogUtils.e(TAG, "【裁剪异常】输入图片文件无效");
showToast(activity, "无有效图片可裁剪"); showToast(activity, "无有效图片可裁剪");
return; return;
} }
@@ -51,47 +109,27 @@ public class ImageCropUtils {
return; return;
} }
// 生成输入/输出Uri适配FileProvider // 2. 核心:强制修正输出为 PNG忽略原图格式
Uri inputUri = getFileProviderUri(activity, inputFile); 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 uCrop = UCrop.of(inputUri, outputUri);
UCrop.Options options = new UCrop.Options(); UCrop.Options options = initCropOptions(activity, isFreeCrop, aspectX, aspectY); // 移除 isPng 参数
// 裁剪模式配置(自由裁剪/固定比例) // 4. 启动裁剪
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)); // 状态栏颜色
// 应用配置并启动裁剪
uCrop.withOptions(options); uCrop.withOptions(options);
// 启动uCrop裁剪Activity替代系统裁剪
uCrop.start(activity, requestCode); uCrop.start(activity, requestCode);
LogUtils.d(TAG, "【裁剪启动成功File 版)】强制输出 PNG透明保留输出路径" + outputFile.getAbsolutePath());
LogUtils.d(TAG, "【uCrop启动】成功输入Uri" + inputUri + "输出Uri" + outputUri + ",请求码:" + requestCode);
} }
/** /**
* 重载方法:适配BackgroundBean * BackgroundBean 传参版】启动 uCrop 裁剪 - 强制输出 PNG保留透明通道
*/ */
public static void startImageCrop(Activity activity, public static void startImageCrop(Activity activity,
BackgroundBean cropBean, BackgroundBean cropBean,
int aspectX, int aspectX,
int aspectY, int aspectY,
boolean isFreeCrop, boolean isFreeCrop,
int requestCode) { int requestCode) {
@@ -100,70 +138,153 @@ public class ImageCropUtils {
startImageCrop(activity, inputFile, outputFile, aspectX, aspectY, isFreeCrop, requestCode); startImageCrop(activity, inputFile, outputFile, aspectX, aspectY, isFreeCrop, requestCode);
} }
/** // ====================== 裁剪结果处理(保持兼容,优化日志)======================
* 生成FileProvider Uri public static String handleCropResult(int requestCode, int resultCode, Intent data, int cropRequestCode) {
*/ if (requestCode != cropRequestCode) return null;
private static Uri getFileProviderUri(Activity activity, File file) {
try { if (resultCode == Activity.RESULT_OK && data != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { Uri outputUri = UCrop.getOutput(data);
String authority = activity.getPackageName() + FILE_PROVIDER_SUFFIX; if (outputUri != null) {
Uri uri = FileProvider.getUriForFile(activity, authority, file); String outputPath = uriToPath(outputUri);
LogUtils.d(TAG, "Uri生成】FileProvider Uri" + uri); LogUtils.d(TAG, "裁剪成功】强制输出 PNG透明保留输出路径" + outputPath);
return uri; return outputPath;
} else {
Uri uri = Uri.fromFile(file);
LogUtils.d(TAG, "【Uri生成】普通Uri" + uri);
return uri;
} }
} 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) { } 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; return null;
} }
} }
/** /**
* 处理uCrop裁剪回调在Activity的onActivityResult中调用 * 统一初始化裁剪配置(强制 PNG 专属配置,保留透明核心
* @param requestCode 请求码 * 移除 isPng 参数,全程用 PNG 配置
* @param resultCode 结果码
* @param data 回调数据
* @return 裁剪成功返回输出文件路径失败返回null
*/ */
public static String handleCropResult(int requestCode, int resultCode, Intent data, int cropRequestCode) { private static UCrop.Options initCropOptions(Activity activity, boolean isFreeCrop, int aspectX, int aspectY) {
// 校验是否是uCrop的回调 UCrop.Options options = new UCrop.Options();
if (requestCode == cropRequestCode) {
if (resultCode == Activity.RESULT_OK && data != null) { // 1. 裁剪模式配置
// 裁剪成功获取输出Uri options.setFreeStyleCropEnabled(isFreeCrop);
Uri outputUri = UCrop.getOutput(data);
if (outputUri != null) { // 2. 核心:强制 PNG 保留透明(固定配置,无需判断原图格式)
String outputPath = outputUri.getPath(); options.setCompressionFormat(FORCE_COMPRESS_FORMAT); // 强制 PNG 压缩
LogUtils.d(TAG, "【uCrop回调】裁剪成功输出路径" + outputPath); options.setCompressionQuality(100); // PNG 100% 质量,不损失透明
return outputPath; options.setDimmedLayerColor(activity.getResources().getColor(android.R.color.transparent)); // 遮罩透明(关键)
} options.setCropFrameColor(activity.getResources().getColor(R.color.colorPrimary)); // 裁剪框主题色
} else if (resultCode == UCrop.RESULT_ERROR) { options.setCropGridColor(activity.getResources().getColor(R.color.colorAccent)); // 网格线主题色
// 裁剪失败,获取异常信息
Throwable error = UCrop.getError(data); // 3. 通用 UI 配置(保持原有风格)
LogUtils.e(TAG, "【uCrop回调】裁剪失败" + (error != null ? error.getMessage() : "未知错误")); options.setHideBottomControls(true); // 隐藏底部控制栏
} else { options.setToolbarTitle("图片裁剪");
LogUtils.d(TAG, "【uCrop回调】裁剪被取消"); 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 null;
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) { private static void showToast(Activity activity, String msg) {
if (activity != null && !activity.isFinishing()) { if (activity != null && !activity.isFinishing()) {
android.widget.Toast.makeText(activity, msg, android.widget.Toast.LENGTH_SHORT).show(); android.widget.Toast.makeText(activity, msg, android.widget.Toast.LENGTH_SHORT).show();
} }
} }
/** // ====================== 公有辅助方法(供外部调用)======================
* 暴露getFileProviderUri方法供外部调用
*/
public static Uri getFileProviderUriPublic(Activity activity, File file) { public static Uri getFileProviderUriPublic(Activity activity, File file) {
return getFileProviderUri(activity, 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.ComponentName;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Environment;
import android.os.PowerManager; import android.os.PowerManager;
import android.provider.Settings; 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.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> * @Author ZhanGSKen&豆包大模型<zhangsken@qq.com>
* @Date 2025/12/14 03:05 * @Date 2025/12/14 03:05
* @Describe 权限申请工具类Java7兼容版 * @Describe 权限申请工具类Java7兼容版
* 适配 小米手机+API30专注自启动权限、电池优化权限检查与申请 * 适配 小米手机+API29-30整合自启动、电池优化、全文件管理权限,专注后台保活核心权限
*/ */
public class PermissionUtils { public class PermissionUtils {
// ====================== 常量定义(统一管理,首屏可见====================== // ====================== 常量定义(首屏可见,统一管理,避免冲突======================
// 日志标签
public static final String TAG = "PermissionUtils"; public static final String TAG = "PermissionUtils";
// 权限请求码(仅保留核心权限场景 // 权限请求码(按场景分段,避免重复
public static final int REQUEST_IGNORE_BATTERY_OPTIMIZATION = 1000; // 电池优化权限 public static final int REQUEST_IGNORE_BATTERY_OPTIMIZATION = 1000; // 电池优化权限
public static final int REQUEST_AUTO_START = 1001; // 自启动权限(小米专属) 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 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_PACKAGE = "com.miui.securitycenter";
private static final String XIAOMI_AUTO_START_CLASS = "com.miui.permcenter.autostart.AutoStartManagementActivity"; private static final String XIAOMI_AUTO_START_CLASS = "com.miui.permcenter.autostart.AutoStartManagementActivity";
// ====================== 单例模式Java7标准双重校验锁====================== // ====================== 单例模式Java7标准双重校验锁,线程安全+懒加载======================
private static volatile PermissionUtils sInstance; private static volatile PermissionUtils sInstance;
private PermissionUtils() {} private PermissionUtils() {}
@@ -39,81 +51,148 @@ public class PermissionUtils {
synchronized (PermissionUtils.class) { synchronized (PermissionUtils.class) {
if (sInstance == null) { if (sInstance == null) {
sInstance = new PermissionUtils(); sInstance = new PermissionUtils();
LogUtils.d(TAG, "【单例初始化PermissionUtils 例创建成功"); LogUtils.d(TAG, "初始化PermissionUtils 例创建成功");
} }
} }
} }
return sInstance; return sInstance;
} }
// ====================== 自启动权限(拆分检查+请求,小米专属====================== // ====================== 核心权限1全文件管理权限API29-30适配通用所有机型======================
/** /**
* 检查是否拥有自启动权限小米手机专属判断API30适配 * 检查全文件管理权限适配API30+ MANAGE_EXTERNAL_STORAGE兼容API29-旧权限
* 注小米自启动无系统API直接校验通过「是否为小米机型」+「功能场景间接判断」,此处返回机型适配状态
* @param activity 上下文Activity不可为null * @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) { if (activity == null) {
LogUtils.e(TAG, "【自启动权限-检查失败Activity为空"); LogUtils.e(TAG, "全文件权限-检查失败Activity为空");
return false; return false;
} }
LogUtils.d(TAG, "【自启动权限-检查】开始,设备品牌:" + Build.BRAND + ",系统版本:" + Build.VERSION.SDK_INT);
// 仅小米机型需要申请自启动权限非小米直接返回false无需处理 // API30+:校验 MANAGE_EXTERNAL_STORAGE 特殊权限
boolean isXiaomi = Build.BRAND.toLowerCase().contains("xiaomi"); if (Build.VERSION.SDK_INT >= SDK_VERSION_R) {
LogUtils.d(TAG, "【自启动权限-检查】结果:" + (isXiaomi ? "小米机型(需手动开启)" : "非小米机型(无需申请)")); boolean hasManagePerm = Environment.isExternalStorageManager();
return isXiaomi; 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 * @param activity 申请权限的Activity不可为null
*/ */
public void requestAutoStartPermission(Activity activity) { public void requestAutoStartPermission(Activity activity) {
if (activity == null) { LogUtils.d(TAG, "自启动权限-申请:开始处理");
LogUtils.e(TAG, "【自启动权限-请求】失败Activity为空"); if (activity == null || activity.isFinishing()) {
LogUtils.e(TAG, "自启动权限-申请失败Activity无效/已销毁");
return; 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) { if (Build.VERSION.SDK_INT >= SDK_VERSION_R) {
try { try {
// 方案1组件名精准跳转成功率最高 // 方案1组件名精准跳转成功率最高
Intent intent = new Intent(); Intent intent = new Intent();
intent.setComponent(new ComponentName(XIAOMI_AUTO_START_PACKAGE, XIAOMI_AUTO_START_CLASS)); intent.setComponent(new ComponentName(XIAOMI_AUTO_START_PACKAGE, XIAOMI_AUTO_START_CLASS));
activity.startActivityForResult(intent, REQUEST_AUTO_START); activity.startActivityForResult(intent, REQUEST_AUTO_START);
LogUtils.d(TAG, "自启动权限-请求】跳转小米自启动管理页(组件名跳转)"); LogUtils.d(TAG, "自启动权限-申请API30+,组件名跳转自启动管理页");
} catch (Exception e1) { } catch (Exception e1) {
try { try {
// 方案2Action备用跳转兼容机型差异 // 方案2Action备用跳转兼容机型差异
Intent intent = new Intent("miui.intent.action.OP_AUTO_START"); Intent intent = new Intent("miui.intent.action.OP_AUTO_START");
intent.setClassName(XIAOMI_AUTO_START_PACKAGE, XIAOMI_AUTO_START_CLASS); intent.setClassName(XIAOMI_AUTO_START_PACKAGE, XIAOMI_AUTO_START_CLASS);
activity.startActivityForResult(intent, REQUEST_AUTO_START); activity.startActivityForResult(intent, REQUEST_AUTO_START);
LogUtils.d(TAG, "自启动权限-请求】跳转小米自启动管理页(Action跳转"); LogUtils.d(TAG, "自启动权限-申请API30+Action跳转自启动管理页");
} catch (Exception e2) { } catch (Exception e2) {
// 方案3终极备用跳转系统设置,引导手动操作) // 方案3终极备用跳转系统设置+提示
Intent intent = new Intent(Settings.ACTION_SETTINGS); Intent intent = new Intent(Settings.ACTION_SETTINGS);
activity.startActivityForResult(intent, REQUEST_AUTO_START); activity.startActivityForResult(intent, REQUEST_AUTO_START);
LogUtils.w(TAG, "自启动权限-请求】跳转系统设置页(引导手动开启)"); LogUtils.w(TAG, "自启动权限-申请:跳转失败,引导手动操作");
showAutoStartTipsDialog(activity); showAutoStartTipsDialog(activity);
} }
} }
return; return;
} }
// API30以下小米手机兼容低版本跳转逻辑 // API29 小米:低版本兼容跳转
LogUtils.d(TAG, "【自启动权限-请求】API30以下小米机型执行低版本跳转");
try { try {
Intent intent = new Intent(XIAOMI_AUTO_START_CLASS); Intent intent = new Intent(XIAOMI_AUTO_START_CLASS);
intent.setPackage(XIAOMI_AUTO_START_PACKAGE); intent.setPackage(XIAOMI_AUTO_START_PACKAGE);
activity.startActivityForResult(intent, REQUEST_AUTO_START); activity.startActivityForResult(intent, REQUEST_AUTO_START);
LogUtils.d(TAG, "自启动权限-申请API29低版本跳转自启动管理页");
} catch (Exception e) { } catch (Exception e) {
Intent intent = new Intent(Settings.ACTION_SETTINGS); Intent intent = new Intent(Settings.ACTION_SETTINGS);
activity.startActivityForResult(intent, REQUEST_AUTO_START); activity.startActivityForResult(intent, REQUEST_AUTO_START);
@@ -121,75 +200,94 @@ public class PermissionUtils {
} }
} }
// ====================== 电池优化权限(拆分检查+请求,通用所有机型)====================== // ====================== 核心权限3电池优化权限(通用所有机型API29-30适配======================
/** /**
* 检查是否拥有「忽略电池优化权限(API30适配通用所有机型精准返回权限状态 * 检查忽略电池优化权限(精准判断API23+有效,低版本视为已拥有
* @param activity 上下文Activity不可为null * @param activity 上下文Activity不可为null
* @return true:已拥有(忽略优化false:未拥有(需申请) * @return true=已忽略优化false=未忽略(需申请)
*/ */
public boolean checkIgnoreBatteryOptimizationPermission(Activity activity) { public boolean checkIgnoreBatteryOptimizationPermission(Activity activity) {
LogUtils.d(TAG, "电池优化权限-检查:开始,系统版本=" + Build.VERSION.SDK_INT);
if (activity == null) { if (activity == null) {
LogUtils.e(TAG, "电池优化权限-检查失败Activity为空"); LogUtils.e(TAG, "电池优化权限-检查失败Activity为空");
return false; return false;
} }
LogUtils.d(TAG, "【电池优化权限-检查】开始,系统版本:" + Build.VERSION.SDK_INT);
// API23以下无电池优化权限,直接视为已拥有 // API23以下无权限,视为已拥有
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
LogUtils.d(TAG, "电池优化权限-检查API23以下无此权限,视为已拥有"); LogUtils.d(TAG, "电池优化权限-检查API23以下,无需校验,视为已拥有");
return true; return true;
} }
// API23+ 精准校验权限状态 // API23+ 精准校验权限状态
PowerManager powerManager = (PowerManager) activity.getSystemService(Activity.POWER_SERVICE); PowerManager powerManager = (PowerManager) activity.getSystemService(Activity.POWER_SERVICE);
if (powerManager == null) { if (powerManager == null) {
LogUtils.e(TAG, "电池优化权限-检查获取PowerManager失败无法校验"); LogUtils.e(TAG, "电池优化权限-检查获取PowerManager失败校验异常");
return false; return false;
} }
boolean isIgnored = powerManager.isIgnoringBatteryOptimizations(activity.getPackageName()); boolean isIgnored = powerManager.isIgnoringBatteryOptimizations(activity.getPackageName());
LogUtils.d(TAG, "电池优化权限-检查结果" + (isIgnored ? "拥有(忽略优化" : "拥有(需申请)")); LogUtils.d(TAG, "电池优化权限-检查结果=" + (isIgnored ? "已忽略优化" : "忽略(需申请)"));
return isIgnored; return isIgnored;
} }
/** /**
* 请求忽略电池优化权限(API30适配通用所有机型,自动判断是否需要申请) * 请求忽略电池优化权限(多方案跳转适配API29-30,自动判断是否需要申请)
* @param activity 申请权限的Activity不可为null * @param activity 申请权限的Activity不可为null
*/ */
public void requestIgnoreBatteryOptimizationPermission(Activity activity) { public void requestIgnoreBatteryOptimizationPermission(Activity activity) {
if (activity == null) { LogUtils.d(TAG, "电池优化权限-申请:开始处理");
LogUtils.e(TAG, "【电池优化权限-请求】失败Activity为空"); if (activity == null || activity.isFinishing()) {
LogUtils.e(TAG, "电池优化权限-申请失败Activity无效/已销毁");
return; return;
} }
// 先检查权限,已拥有则直接返回
// 已拥有权限,直接返回
if (checkIgnoreBatteryOptimizationPermission(activity)) { if (checkIgnoreBatteryOptimizationPermission(activity)) {
LogUtils.d(TAG, "电池优化权限-请求】已拥有权限,无需发起申请"); LogUtils.d(TAG, "电池优化权限-申请:已拥有权限,无需发起");
return; return;
} }
LogUtils.w(TAG, "【电池优化权限-请求】未拥有权限,开始发起申请");
try { try {
// 方案1直接跳转权限申请页(用户一键同意,优先使用 // 方案1直接跳转一键授权页(优先使用,用户操作简单
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + activity.getPackageName())); intent.setData(Uri.parse("package:" + activity.getPackageName()));
activity.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATION); activity.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATION);
LogUtils.d(TAG, "电池优化权限-请求】跳转系统权限申请页"); LogUtils.d(TAG, "电池优化权限-申请:跳转一键授权");
} catch (Exception e) { } catch (Exception e) {
// 方案2备用跳转(跳转优化管理列表,引导手动选择) // 方案2备用跳转优化管理页+提示
Intent intent = new Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS); Intent intent = new Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS);
activity.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATION); activity.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATION);
LogUtils.w(TAG, "电池优化权限-请求】跳转优化管理页(引导手动开启)"); LogUtils.w(TAG, "电池优化权限-申请:跳转失败,引导手动操作");
showBatteryOptTipsDialog(activity); 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) { private void showAutoStartTipsDialog(final Activity activity) {
new AlertDialog.Builder(activity) new AlertDialog.Builder(activity)
.setTitle("权限申请提示") .setTitle("自启动权限申请提示")
.setMessage("请手动开启自启动权限,否则应用后台功能可能异常:\n1. 进入安全中心 → 应用管理 → 自启动管理\n2. 找到本应用,开启「允许自启动」开关") .setMessage("请手动开启自启动权限,否则应用后台保活异常:\n1. 进入小米安全中心 → 应用管理 → 自启动管理\n2. 找到本应用,开启「允许自启动」开关")
.setPositiveButton("知道了", new DialogInterface.OnClickListener() { .setPositiveButton("知道了", new DialogInterface.OnClickListener() {
@Override @Override
public void onClick(DialogInterface dialog, int which) { public void onClick(DialogInterface dialog, int which) {
@@ -198,16 +296,16 @@ public class PermissionUtils {
}) })
.setCancelable(false) .setCancelable(false)
.show(); .show();
LogUtils.d(TAG, "自启动权限显示手动开启提示弹窗"); LogUtils.d(TAG, "自启动权限显示手动开启提示弹窗");
} }
/** /**
* 显示电池优化权限手动开启提示弹窗(跳转失败时使用) * 电池优化权限手动开启提示弹窗
*/ */
private void showBatteryOptTipsDialog(final Activity activity) { private void showBatteryOptTipsDialog(final Activity activity) {
new AlertDialog.Builder(activity) new AlertDialog.Builder(activity)
.setTitle("权限申请提示") .setTitle("电池优化权限申请提示")
.setMessage("请手动忽略电池优化,否则应用后台运行可能被限制:\n1. 进入设置 → 电池 → 电池优化\n2. 找到本应用,选择「不优化」") .setMessage("请手动忽略电池优化,否则应用后台运行被限制:\n1. 进入设置 → 电池 → 电池优化\n2. 找到本应用,选择「不优化」选项")
.setPositiveButton("知道了", new DialogInterface.OnClickListener() { .setPositiveButton("知道了", new DialogInterface.OnClickListener() {
@Override @Override
public void onClick(DialogInterface dialog, int which) { public void onClick(DialogInterface dialog, int which) {
@@ -216,7 +314,37 @@ public class PermissionUtils {
}) })
.setCancelable(false) .setCancelable(false)
.show(); .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; 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.ContentResolver;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@@ -19,199 +14,468 @@ import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; 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 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 转文件后缀 ======================
/** /**
* 获取真实路径 * 【静态公共方法】根据 Uri 获取文件真实后缀优先MIME类型匹配适配所有Uri场景+小米机型)
* * @param context 上下文非空用于获取ContentResolver
* @param context * @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) { 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; return null;
} }
switch (uri.getScheme()) { if (uri == null) {
case ContentResolver.SCHEME_CONTENT: LogUtils.e(TAG, "getFilePathFromUriUri 为空,转换失败");
//Android7.0之后的uri content:// URI return null;
return getFilePathFromContentUri(context, uri);
case ContentResolver.SCHEME_FILE:
default:
//Android7.0之前的uri file://
return new File(uri.getPath()).getAbsolutePath();
} }
}
/** String scheme = uri.getScheme();
* 从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 filePath = null; String filePath = null;
// 按 Uri Scheme 分类处理
if (uri.getAuthority() != null) { if (ContentResolver.SCHEME_CONTENT.equals(scheme)) {
try { LogUtils.d(TAG, "getFilePathFromUriScheme=content执行ContentUri转换");
inputStream = context.getContentResolver().openInputStream(uri); filePath = getFilePathFromContentUri(context, uri);
File file = createTemporalFileFrom(context, inputStream, fileName); } else if (ContentResolver.SCHEME_FILE.equals(scheme)) {
filePath = file.getPath(); LogUtils.d(TAG, "getFilePathFromUriScheme=file直接转换路径");
filePath = new File(uri.getPath()).getAbsolutePath();
} catch (Exception e) { } else {
} finally { LogUtils.w(TAG, "getFilePathFromUri未知Scheme=" + scheme + ",转换失败");
try {
if (inputStream != null) {
inputStream.close();
}
} catch (Exception e) {
}
}
} }
LogUtils.d(TAG, "=== getFilePathFromUri 调用 end结果" + filePath + " ===");
return 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对象的绝对路径和存在性 * 文件路径转 Uri核心方法适配 Android7.0+ FileProviderAPI29-30兼容
LogUtils.d(TAG, "getUriForFile -> 文件绝对路径:" + file.getAbsolutePath()); * @param context 上下文(非空)
LogUtils.d(TAG, "getUriForFile -> 文件是否存在:" + file.exists()); * @param filePath 真实文件路径(非空)
LogUtils.d(TAG, "getUriForFile -> 是否为目录:" + file.isDirectory()); * @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. 合法性校验 // 2. File 对象初始化与校验
if (!file.exists() || file.isDirectory()) { File file = new File(filePath);
LogUtils.e(TAG, "getUriForFile -> 非法路径:文件不存在或为目录"); LogUtils.d(TAG, "getUriForFile:文件路径=" + file.getAbsolutePath() + ",是否存在=" + file.exists());
return null; if (!file.exists() || file.isDirectory()) {
} LogUtils.e(TAG, "getUriForFile文件不存在或为目录转换失败");
return null;
}
// 4. 校验路径是否在配置的合法目录内 // 3. 合法路径校验适配小米机型避免FileProvider配置外路径
String appFilesDir = context.getExternalFilesDir(null) != null ? context.getExternalFilesDir(null).getAbsolutePath() : "null"; if (!isPathInValidDir(context, file)) {
String publicPicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).getAbsolutePath() + "/PowerBell/"; LogUtils.w(TAG, "getUriForFile路径不在安全配置目录内小米机型可能出现权限异常");
String internalFilesDir = context.getFilesDir().getAbsolutePath(); }
String cacheDir = context.getCacheDir().getAbsolutePath();
String absolutePath = file.getAbsolutePath(); // 4. 调用重载方法生成 Uri
boolean isInConfigDir = absolutePath.startsWith(appFilesDir) Uri uri = getUriForFile(context, file);
|| absolutePath.startsWith(publicPicDir) LogUtils.d(TAG, "=== getUriForFile路径版调用 end结果" + (uri != null ? uri.toString() : "null") + " ===");
|| absolutePath.startsWith(internalFilesDir) return uri;
|| absolutePath.startsWith(cacheDir); }
LogUtils.d(TAG, "getUriForFile -> 路径是否在配置目录内:" + isInConfigDir);
if (!isInConfigDir) {
LogUtils.w(TAG, "getUriForFile -> 路径不在FileProvider配置范围内可能导致异常");
// 非强制拦截,保留原有逻辑,仅警告
}
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) { // 2. 按系统版本生成 UriAPI24+ 强制 FileProvider适配小米机型
if (context == null) { Uri uri = null;
LogUtils.e(TAG, "getUriForFile -> Context为空"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return null; LogUtils.d(TAG, "getUriForFileAndroid7.0+使用FileProvider生成Uri");
} String authority = context.getPackageName() + FILE_PROVIDER_SUFFIX;
if (file == null) { LogUtils.d(TAG, "getUriForFileFileProvider Authority=" + authority);
LogUtils.e(TAG, "getUriForFile -> File对象为空"); try {
return null; 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, "=== getUriForFileFile版调用 end ===");
LogUtils.d(TAG, "getUriForFile(File) -> 文件路径:" + file.getAbsolutePath()); return uri;
if (!file.exists() || file.isDirectory()) { }
LogUtils.e(TAG, "getUriForFile(File) -> 文件不存在或为目录");
return null;
}
// 2. 版本判断与Uri生成 // ====================== 私有辅助方法(内部逻辑封装,不对外暴露)======================
if (Build.VERSION.SDK_INT >= 24) { /**
LogUtils.d(TAG, "getUriForFile -> Android 7.0+使用FileProvider生成Uri"); * ContentUri 转真实路径(适配 content:// 格式处理小米机型特殊Uri
try { * @param context 上下文
String authority = context.getPackageName() + ".fileprovider"; * @param uri ContentUricontent://media/external/file/xxx
LogUtils.d(TAG, "getUriForFile -> FileProvider authority" + authority); * @return 真实文件路径(失败返回 null
Uri uri = FileProvider.getUriForFile(context, authority, file); */
LogUtils.d(TAG, "getUriForFile -> 生成Content Uri成功:" + uri.toString()); private static String getFilePathFromContentUri(Context context, Uri uri) {
return uri; LogUtils.d(TAG, "getFilePathFromContentUriUri=" + uri.toString());
} catch (IllegalArgumentException e) { String filePath = null;
LogUtils.e(TAG, "getUriForFile -> FileProvider生成Uri失败路径未配置或权限不足", e); Cursor cursor = null;
return null; // Java7 语法try-catch-finally 手动关闭Cursor避免内存泄漏
} try {
} else { // 查询字段:优先 DATA 字段,失败则通过文件名+流拷贝获取
LogUtils.d(TAG, "getUriForFile -> Android 7.0以下使用Uri.fromFile生成Uri"); String[] queryColumns = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME};
Uri uri = Uri.fromFile(file); cursor = context.getContentResolver().query(uri, queryColumns, null, null, null);
LogUtils.d(TAG, "getUriForFile -> 生成File Uri成功" + uri.toString()); if (cursor != null && cursor.moveToFirst()) {
return uri; // 优先读取 DATA 字段(直接获取路径)
} int dataIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATA);
} if (dataIndex != -1) {
filePath = cursor.getString(dataIndex);
private static File createTemporalFileFrom(Context context, InputStream inputStream, String fileName) LogUtils.d(TAG, "getFilePathFromContentUri从DATA字段获取路径=" + filePath);
throws IOException { } 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; 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) { if (inputStream != null) {
int read;
byte[] buffer = new byte[8 * 1024]; byte[] buffer = new byte[8 * 1024];
//自己定义拷贝文件路径 int readLength;
targetFile = new File(context.getExternalCacheDir(), fileName); targetFile = new File(context.getExternalCacheDir(), fileName);
if (targetFile.exists()) { if (targetFile.exists()) {
targetFile.delete(); targetFile.delete();
} }
OutputStream outputStream = new FileOutputStream(targetFile); OutputStream outputStream = new FileOutputStream(targetFile);
while ((readLength = inputStream.read(buffer)) != -1) {
while ((read = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, readLength);
outputStream.write(buffer, 0, read);
} }
outputStream.flush(); outputStream.flush();
outputStream.close();
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
} }
return targetFile; 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 : "";
}
} }