合规:去除不满足要求的READ_MEDIA_IMAGES权限

This commit is contained in:
ivan 2026-03-30 12:44:06 +08:00
parent b05d3c0b0e
commit 42d9cf3fd2
5 changed files with 109 additions and 490 deletions

View File

@ -2,7 +2,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- 选图走 image_picker / 系统照片选择器 (Photo Picker),不声明 READ_MEDIA_* -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- 与 camera_android_camerax 中 maxSdkVersion=28 合并冲突:沿用本应用上限 API 32 -->
<uses-permission

View File

@ -60,12 +60,6 @@
@import permission_handler_apple;
#endif
#if __has_include(<photo_manager/PhotoManagerPlugin.h>)
#import <photo_manager/PhotoManagerPlugin.h>
#else
@import photo_manager;
#endif
#if __has_include(<screen_secure/ScreenSecurePlugin.h>)
#import <screen_secure/ScreenSecurePlugin.h>
#else
@ -120,7 +114,6 @@
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[InAppPurchasePlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppPurchasePlugin"]];
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
[PhotoManagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PhotoManagerPlugin"]];
[ScreenSecurePlugin registerWithRegistrar:[registry registrarForPlugin:@"ScreenSecurePlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:video_player/video_player.dart';
import '../../core/auth/auth_service.dart';
@ -104,13 +105,28 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => SizedBox(
height: MediaQuery.sizeOf(ctx).height * 0.92,
builder: (ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
),
child: const AlbumPickerSheet(),
),
);
if (!mounted) return;
if (path == kAlbumPickerRequestGallery) {
final picker = ImagePicker();
final x = await picker.pickImage(source: ImageSource.gallery);
if (!mounted) return;
if (x != null && x.path.isNotEmpty) {
final f = File(x.path);
if (await f.exists()) {
await _runGenerationApi(f);
}
}
return;
}
if (path == kAlbumPickerRequestCamera) {
// GPU
setState(() => _suspendBackgroundVideo = true);

View File

@ -1,10 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:photo_manager/photo_manager.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_spacing.dart';
@ -12,194 +7,13 @@ import '../../../core/theme/app_spacing.dart';
/// [showModalBottomSheet] **** BottomSheet Activity /
const String kAlbumPickerRequestCamera = '__album_picker_camera__';
/// App
class AlbumPickerSheet extends StatefulWidget {
/// [ImagePicker] / Photo Picker Sheet
const String kAlbumPickerRequestGallery = '__album_picker_gallery__';
/// Android 使 READ_MEDIA_*
class AlbumPickerSheet extends StatelessWidget {
const AlbumPickerSheet({super.key});
@override
State<AlbumPickerSheet> createState() => _AlbumPickerSheetState();
}
class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
///
static const int _pageSize = 36;
final ScrollController _scrollController = ScrollController();
bool _busy = true;
String? _error;
AssetPathEntity? _recentAlbum;
final List<AssetEntity> _assets = [];
int _loadedPage = -1;
int _totalCount = 0;
bool _loadingMore = false;
///
PermissionState _permissionState = PermissionState.notDetermined;
@override
void initState() {
super.initState();
_scrollController.addListener(_maybeLoadMore);
// / ModalRoute 线便 ScrollController attach
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _init();
});
}
@override
void dispose() {
_scrollController.removeListener(_maybeLoadMore);
_scrollController.dispose();
super.dispose();
}
Future<void> _init() async {
setState(() {
_busy = true;
_error = null;
});
final state = await PhotoManager.requestPermissionExtend();
if (!mounted) return;
_permissionState = state;
if (!state.hasAccess) {
setState(() {
_busy = false;
_error = state == PermissionState.denied
? 'Photo library access is required. Tap below to open Settings.'
: 'Cannot access photo library.';
});
return;
}
final paths = await PhotoManager.getAssetPathList(
type: RequestType.image,
onlyAll: true,
hasAll: true,
);
if (!mounted) return;
if (paths.isEmpty) {
setState(() {
_busy = false;
_recentAlbum = null;
_totalCount = 0;
_assets.clear();
_loadedPage = -1;
});
return;
}
_recentAlbum = paths.first;
_totalCount = await _recentAlbum!.assetCountAsync;
if (!mounted) return;
if (_totalCount == 0) {
setState(() {
_assets.clear();
_loadedPage = -1;
_busy = false;
});
return;
}
final first = await _recentAlbum!.getAssetListPaged(page: 0, size: _pageSize);
if (!mounted) return;
setState(() {
_assets
..clear()
..addAll(first);
_loadedPage = 0;
_busy = false;
});
}
/// iOSAndroid
Future<void> _openManagePhotoAccess() async {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
try {
await PhotoManager.presentLimited();
} catch (_) {
await PhotoManager.openSetting();
return;
}
} else {
await PhotoManager.openSetting();
return;
}
if (mounted) await _init();
}
void _maybeLoadMore() {
if (_loadingMore || _busy || _recentAlbum == null) return;
if (_assets.length >= _totalCount) return;
// attach 访 .position Generate /退
if (!_scrollController.hasClients) return;
final pos = _scrollController.position;
final maxExtent = pos.maxScrollExtent;
if (!maxExtent.isFinite || maxExtent <= 0) return;
if (pos.pixels < maxExtent - 380) return;
_loadMore();
}
Future<void> _loadMore() async {
final album = _recentAlbum;
if (album == null || _loadingMore) return;
if (_assets.length >= _totalCount) return;
setState(() => _loadingMore = true);
try {
final nextPage = _loadedPage + 1;
final list =
await album.getAssetListPaged(page: nextPage, size: _pageSize);
if (!mounted) return;
if (list.isNotEmpty) {
setState(() {
_assets.addAll(list);
_loadedPage = nextPage;
});
}
} finally {
if (mounted) setState(() => _loadingMore = false);
}
}
Future<void> _onCamera() async {
if (!mounted) return;
// pickImageBottomSheet Activity Surface
Navigator.of(context).pop<String>(kAlbumPickerRequestCamera);
}
Future<void> _onAsset(AssetEntity e) async {
final f = await e.file;
if (!mounted) return;
if (f != null) {
Navigator.of(context).pop<String>(f.path);
} else if (mounted) {
final limited = _permissionState == PermissionState.limited;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
limited
? 'Cannot read this photo. It may not be included in your '
'current selection. Use "Manage photo access" or choose '
'another image.'
: 'Could not open this photo. Try another.',
),
behavior: SnackBarBehavior.floating,
),
);
}
}
bool get _showNoPhotosHelp =>
!_busy && _error == null && _totalCount == 0;
static const int _crossCount = 3;
static const double _spacing = 3;
@override
Widget build(BuildContext context) {
final bottomPad = MediaQuery.paddingOf(context).bottom;
@ -210,6 +24,7 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
@ -240,93 +55,32 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
),
),
const Divider(height: 1, color: AppColors.border),
Expanded(
child: _busy
? const Center(
child: CircularProgressIndicator(color: AppColors.primary),
)
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.lg),
TextButton(
onPressed: () async {
await PhotoManager.openSetting();
},
child: const Text('Open Settings'),
),
TextButton(
onPressed: _init,
child: const Text('Try again'),
),
],
),
),
)
: _showNoPhotosHelp
? _NoPhotosInLibraryPanel(
isLimitedAccess:
_permissionState == PermissionState.limited,
onManageAccess: _openManagePhotoAccess,
onOpenSettings: () async {
await PhotoManager.openSetting();
},
onRetry: _init,
onUseCamera: _onCamera,
)
: GridView.builder(
controller: _scrollController,
cacheExtent: 220,
padding: EdgeInsets.fromLTRB(
_spacing,
_spacing,
_spacing,
_spacing + bottomPad,
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _crossCount,
mainAxisSpacing: _spacing,
crossAxisSpacing: _spacing,
childAspectRatio: 1,
),
itemCount:
1 + _assets.length + (_loadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0) {
return _CameraGridTile(onTap: _onCamera);
}
final ai = index - 1;
if (ai >= _assets.length) {
return const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
);
}
final asset = _assets[ai];
return GestureDetector(
onTap: () => _onAsset(asset),
child: _AlbumThumbnail(asset: asset),
);
},
),
Padding(
padding: EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.lg,
AppSpacing.lg,
AppSpacing.xl + bottomPad,
),
child: Column(
children: [
_ActionCard(
icon: LucideIcons.camera,
title: 'Take photo',
subtitle: 'Use camera',
onTap: () => Navigator.of(context)
.pop<String>(kAlbumPickerRequestCamera),
),
const SizedBox(height: AppSpacing.md),
_ActionCard(
icon: LucideIcons.images,
title: 'Choose from gallery',
subtitle: 'System photo picker',
onTap: () => Navigator.of(context)
.pop<String>(kAlbumPickerRequestGallery),
),
],
),
),
],
),
@ -334,220 +88,77 @@ class _AlbumPickerSheetState extends State<AlbumPickerSheet> {
}
}
///
class _NoPhotosInLibraryPanel extends StatelessWidget {
const _NoPhotosInLibraryPanel({
required this.isLimitedAccess,
required this.onManageAccess,
required this.onOpenSettings,
required this.onRetry,
required this.onUseCamera,
class _ActionCard extends StatelessWidget {
const _ActionCard({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final bool isLimitedAccess;
final Future<void> Function() onManageAccess;
final Future<void> Function() onOpenSettings;
final Future<void> Function() onRetry;
final Future<void> Function() onUseCamera;
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.paddingOf(context).bottom;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.xl,
AppSpacing.xl + bottom,
),
child: Column(
children: [
Icon(
LucideIcons.folder_open,
size: 48,
color: AppColors.textSecondary.withValues(alpha: 0.9),
),
const SizedBox(height: AppSpacing.lg),
Text(
isLimitedAccess
? 'No photos shared with this app'
: 'No photos available',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: AppSpacing.md),
Text(
isLimitedAccess
? 'You allowed access to only some photos, or none are '
'selected for this app. Add photos or change permission, '
'then try again.'
: 'We could not load any images. Check photo access in '
'Settings or try again.',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
height: 1.4,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.xl),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.surface,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () => onManageAccess(),
child: Text(
!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS
? 'Select photos'
: 'Manage photo access',
),
),
),
const SizedBox(height: AppSpacing.sm),
TextButton(
onPressed: () => onOpenSettings(),
child: const Text('Open Settings'),
),
TextButton(
onPressed: () => onRetry(),
child: const Text('Try again'),
),
const SizedBox(height: AppSpacing.md),
TextButton.icon(
onPressed: () => onUseCamera(),
icon: const Icon(LucideIcons.camera, size: 20),
label: const Text('Take photo with camera'),
),
],
),
);
}
}
class _CameraGridTile extends StatelessWidget {
const _CameraGridTile({required this.onTap});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.camera,
size: 32,
color: AppColors.primary.withValues(alpha: 0.9),
),
const SizedBox(height: AppSpacing.sm),
Text(
'Camera',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.lg,
),
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, size: 26, color: AppColors.primary),
),
),
],
const SizedBox(width: AppSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
height: 1.3,
color: AppColors.textSecondary.withValues(alpha: 0.95),
),
),
],
),
),
Icon(
LucideIcons.chevron_right,
size: 20,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
],
),
),
),
);
}
}
class _AlbumThumbnail extends StatefulWidget {
const _AlbumThumbnail({required this.asset});
final AssetEntity asset;
@override
State<_AlbumThumbnail> createState() => _AlbumThumbnailState();
}
class _AlbumThumbnailState extends State<_AlbumThumbnail> {
Uint8List? _bytes;
bool _loadFailed = false;
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(covariant _AlbumThumbnail oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.asset.id != widget.asset.id) {
_bytes = null;
_loadFailed = false;
_load();
}
}
Future<void> _load() async {
try {
final data = await widget.asset.thumbnailDataWithSize(
const ThumbnailSize.square(200),
);
if (mounted) {
setState(() {
_bytes = data;
_loadFailed = data == null || data.isEmpty;
});
}
} catch (_) {
if (mounted) {
setState(() {
_bytes = null;
_loadFailed = true;
});
}
}
}
@override
Widget build(BuildContext context) {
final b = _bytes;
return ColoredBox(
color: AppColors.surfaceAlt,
child: b != null && b.isNotEmpty
? Image.memory(
b,
fit: BoxFit.cover,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
)
: _loadFailed
? const Icon(
LucideIcons.image_off,
size: 28,
color: AppColors.textSecondary,
)
: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
),
);
}
}

View File

@ -36,7 +36,6 @@ dependencies:
flutter_native_splash: ^2.4.7
android_id: ^0.5.1
visibility_detector: ^0.4.0+2
photo_manager: ^3.9.0
image: ^4.5.4
camera: ^0.12.0+1
permission_handler: ^12.0.1