refactor: gallery permission notifier (#30477)

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong 2026-08-08 21:44:14 +05:30 committed by GitHub
parent 79e61c47c7
commit e9eafc3161
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 260 additions and 127 deletions

View file

@ -24,3 +24,14 @@ enum SlideshowLook { contain, cover, blurredBackground }
enum SlideshowDirection { forward, backward, shuffle }
enum PartnerDirection { sharedBy, sharedWith }
enum DevicePermission { photos, videos, storage, mediaLocation }
enum DevicePermissionStatus {
denied,
granted,
limited,
permanentlyDenied;
bool get hasAccess => this == granted || this == limited;
}

View file

@ -0,0 +1,47 @@
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
typedef _Handler = Future<DevicePermissionStatus> Function(DevicePermission);
class DevicePermissionService {
const DevicePermissionService(this._permissionRepository);
final DevicePermissionRepository _permissionRepository;
Future<DevicePermissionStatus> galleryStatus() => _gallery(_permissionRepository.getStatus);
Future<DevicePermissionStatus> requestGallery() => _gallery(_permissionRepository.request);
Future<DevicePermissionStatus> _gallery(_Handler handler) async {
if (CurrentPlatform.isIOS) {
return handler(.photos);
}
final sdkVersion = await _permissionRepository.getAndroidSdkVersion();
const maxExternalStorageSdk = 32; // READ/WRITE_EXTERNAL_STORAGE - Android 12.1
final status = sdkVersion <= maxExternalStorageSdk ? await handler(.storage) : await _photosAndVideos(handler);
const minMediaLocationSdk = 29; // ACCESS_MEDIA_LOCATION - Android 10
if (status.hasAccess && sdkVersion >= minMediaLocationSdk) {
final mediaLocation = await handler(.mediaLocation);
return mediaLocation.hasAccess ? status : mediaLocation;
}
return status;
}
Future<DevicePermissionStatus> _photosAndVideos(_Handler handler) async {
final photos = await handler(.photos);
if (!photos.hasAccess) {
return photos;
}
final videos = await handler(.videos);
if (!videos.hasAccess) {
return videos;
}
return photos == .granted && videos == .granted ? .granted : .limited;
}
}

View file

@ -27,7 +27,7 @@ class LocalSyncService {
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final DevicePermissionRepository _permissionRepository;
final Completer<void>? _cancellation;
final Logger _log = Logger("DeviceSyncService");

View file

@ -36,7 +36,7 @@ class SyncStreamService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final DevicePermissionRepository _permissionRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final Completer<void>? _cancellation;

View file

@ -1,109 +1,28 @@
import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/services/device_permission.service.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
class GalleryPermissionNotifier extends StateNotifier<PermissionStatus> {
GalleryPermissionNotifier()
: super(PermissionStatus.denied) // Denied is the initial state
{
// Sets the initial state
final galleryPermissionServiceProvider = Provider((ref) {
return DevicePermissionService(ref.watch(permissionRepositoryProvider));
});
class GalleryPermissionNotifier extends StateNotifier<DevicePermissionStatus> {
GalleryPermissionNotifier(this._service) : super(.denied) {
unawaited(getGalleryPermissionStatus());
}
bool get hasPermission => state.isGranted || state.isLimited;
final DevicePermissionService _service;
/// Requests the gallery permission
Future<PermissionStatus> requestGalleryPermission() async {
PermissionStatus result;
// Android 32 and below uses Permission.storage
if (Platform.isAndroid) {
final androidInfo = await DeviceInfoPlugin().androidInfo;
if (androidInfo.version.sdkInt <= 32) {
// Android 32 and below need storage
final permission = await Permission.storage.request();
result = permission;
} else {
// Android 33 need photo & video
final photos = await Permission.photos.request();
if (!photos.isGranted) {
// Don't ask twice for the same permission
state = photos;
return photos;
}
final videos = await Permission.videos.request();
bool get hasPermission => state.hasAccess;
// Return the joint result of those two permissions
final PermissionStatus status;
if ((photos.isGranted && videos.isGranted) || (photos.isLimited && videos.isLimited)) {
status = PermissionStatus.granted;
} else if (photos.isDenied || videos.isDenied) {
status = PermissionStatus.denied;
} else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) {
status = PermissionStatus.permanentlyDenied;
} else {
status = PermissionStatus.denied;
Future<DevicePermissionStatus> requestGalleryPermission() async => state = await _service.requestGallery();
Future<DevicePermissionStatus> getGalleryPermissionStatus() async => state = await _service.galleryStatus();
}
result = status;
}
if (result == PermissionStatus.granted && androidInfo.version.sdkInt >= 29) {
result = await Permission.accessMediaLocation.request();
}
} else {
// iOS can use photos
final photos = await Permission.photos.request();
result = photos;
}
state = result;
return result;
}
/// Checks the current state of the gallery permissions without
/// requesting them again
Future<PermissionStatus> getGalleryPermissionStatus() async {
PermissionStatus result;
// Android 32 and below uses Permission.storage
if (Platform.isAndroid) {
final androidInfo = await DeviceInfoPlugin().androidInfo;
if (androidInfo.version.sdkInt <= 32) {
// Android 32 and below need storage
final permission = await Permission.storage.status;
result = permission;
} else {
// Android 33 needs photo & video
final photos = await Permission.photos.status;
final videos = await Permission.videos.status;
// Return the joint result of those two permissions
final PermissionStatus status;
if ((photos.isGranted && videos.isGranted) || (photos.isLimited && videos.isLimited)) {
status = PermissionStatus.granted;
} else if (photos.isDenied || videos.isDenied) {
status = PermissionStatus.denied;
} else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) {
status = PermissionStatus.permanentlyDenied;
} else {
status = PermissionStatus.denied;
}
result = status;
}
if (state == PermissionStatus.granted && androidInfo.version.sdkInt >= 29) {
result = await Permission.accessMediaLocation.status;
}
} else {
// iOS can use photos
final photos = await Permission.photos.status;
result = photos;
}
state = result;
return result;
}
}
final galleryPermissionNotifier = StateNotifierProvider<GalleryPermissionNotifier, PermissionStatus>(
(ref) => GalleryPermissionNotifier(),
final galleryPermissionNotifier = StateNotifierProvider<GalleryPermissionNotifier, DevicePermissionStatus>(
(ref) => GalleryPermissionNotifier(ref.watch(galleryPermissionServiceProvider)),
);

View file

@ -1,67 +1,83 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:permission_handler/permission_handler.dart' as ph;
final permissionRepositoryProvider = Provider((ref) {
return PermissionRepository(ref.watch(permissionApiProvider));
return DevicePermissionRepository(ref.watch(permissionApiProvider));
});
class PermissionRepository implements IPermissionRepository {
class DevicePermissionRepository {
final PermissionApi _permissionApi;
const PermissionRepository(this._permissionApi);
const DevicePermissionRepository(this._permissionApi);
@override
Future<bool> hasLocationWhenInUsePermission() {
return Permission.locationWhenInUse.isGranted;
Future<DevicePermissionStatus> getStatus(DevicePermission permission) async =>
(await permission.handler.status).toDevicePermissionStatus();
Future<DevicePermissionStatus> request(DevicePermission permission) async =>
(await permission.handler.request()).toDevicePermissionStatus();
// TODO(shenlong): Move this to it's own device info repo
Future<int> getAndroidSdkVersion() async {
if (CurrentPlatform.isIOS) {
throw UnsupportedError("This method is available only on Android");
}
@override
final androidInfo = await DeviceInfoPlugin().androidInfo;
return androidInfo.version.sdkInt;
}
Future<bool> hasLocationWhenInUsePermission() => ph.Permission.locationWhenInUse.isGranted;
Future<bool> requestLocationWhenInUsePermission() async {
final result = await Permission.locationWhenInUse.request();
final result = await ph.Permission.locationWhenInUse.request();
return result.isGranted;
}
@override
Future<bool> hasLocationAlwaysPermission() {
return Permission.locationAlways.isGranted;
return ph.Permission.locationAlways.isGranted;
}
@override
Future<bool> requestLocationAlwaysPermission() async {
final result = await Permission.locationAlways.request();
final result = await ph.Permission.locationAlways.request();
return result.isGranted;
}
@override
Future<bool> openSettings() {
return openAppSettings();
return ph.openAppSettings();
}
@override
Future<bool> hasManageMediaPermission() {
return _permissionApi.hasManageMediaPermission();
}
@override
Future<bool> requestManageMediaPermission() {
return _permissionApi.requestManageMediaPermission();
}
@override
Future<bool> manageMediaPermission() {
return _permissionApi.manageMediaPermission();
}
}
abstract interface class IPermissionRepository {
Future<bool> hasLocationWhenInUsePermission();
Future<bool> requestLocationWhenInUsePermission();
Future<bool> hasLocationAlwaysPermission();
Future<bool> requestLocationAlwaysPermission();
Future<bool> openSettings();
Future<bool> hasManageMediaPermission();
Future<bool> requestManageMediaPermission();
Future<bool> manageMediaPermission();
extension on DevicePermission {
ph.Permission get handler => switch (this) {
.photos => ph.Permission.photos,
.videos => ph.Permission.videos,
.storage => ph.Permission.storage,
.mediaLocation => ph.Permission.accessMediaLocation,
};
}
extension on ph.PermissionStatus {
DevicePermissionStatus toDevicePermissionStatus() => switch (this) {
.granted => .granted,
.limited => .limited,
.permanentlyDenied => .permanentlyDenied,
_ => .denied,
};
}

View file

@ -8,7 +8,7 @@ final networkServiceProvider = Provider((ref) {
class NetworkService {
final NetworkRepository _repository;
final IPermissionRepository _permissionRepository;
final DevicePermissionRepository _permissionRepository;
const NetworkService(this._repository, this._permissionRepository);

View file

@ -12,7 +12,7 @@ class MockAssetApiRepository extends Mock implements AssetApiRepository {}
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
class MockPermissionRepository extends Mock implements IPermissionRepository {}
class MockPermissionRepository extends Mock implements DevicePermissionRepository {}
class MockAuthApiRepository extends Mock implements AuthApiRepository {}

View file

@ -32,6 +32,7 @@ class RepositoryMocks {
final trashedAsset = MockTrashedLocalAssetRepository();
final remoteAlbum = MockRemoteAlbumRepository();
final albumApi = MockDriftAlbumApiRepository();
final permission = PermissionRepositoryStub(MockPermissionRepository());
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
final assetApi = AssetApiRepositoryStub(MockAssetApiRepository());
@ -55,6 +56,7 @@ class RepositoryMocks {
assetApi.reset();
assetMedia.reset();
download.reset();
permission.reset();
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
_stubRemoteAssetRepository();
@ -63,6 +65,7 @@ class RepositoryMocks {
_stubAssetApiRepository();
_stubAssetMediaRepository();
_stubDownloadRepository();
_stubPermissionRepository();
}
void _stubRemoteAssetRepository() {
@ -101,6 +104,12 @@ class RepositoryMocks {
void _stubDownloadRepository() {
when(download.downloadAllAssets).thenAnswer((_) async => const []);
}
void _stubPermissionRepository() {
when(permission.getStatus).thenAnswer((_) async => DevicePermissionStatus.denied);
when(permission.request).thenAnswer((_) async => DevicePermissionStatus.denied);
when(permission.getAndroidSdkVersion).thenAnswer((_) async => 34);
}
}
class ServiceMocks {
@ -220,6 +229,8 @@ void _registerFallbacks() {
registerFallbackValue(ShareAssetType.original);
registerFallbackValue(const UploadCallbacks());
registerFallbackValue(_FakeBuildContext());
registerFallbackValue(DevicePermissionStatus.granted);
registerFallbackValue(DevicePermission.photos);
}
class _FakeBuildContext extends Fake implements BuildContext {}
@ -400,6 +411,17 @@ extension type const DownloadRepositoryStub(MockDownloadRepository repo) impleme
() => repo.downloadAllAssets(any());
}
extension type const PermissionRepositoryStub(MockPermissionRepository repo) implements Stub<MockPermissionRepository> {
Future<DevicePermissionStatus> Function() get getStatus =>
() => repo.getStatus(any());
Future<DevicePermissionStatus> Function() get request =>
() => repo.request(any());
Future<int> Function() get getAndroidSdkVersion =>
() => repo.getAndroidSdkVersion();
}
extension type const TagServiceStub(MockTagService service) implements Stub<MockTagService> {
Future<int> Function() get bulkTagAssets =>
() => service.bulkTagAssets(any(), any());

View file

@ -0,0 +1,118 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/services/device_permission.service.dart';
import 'package:mocktail/mocktail.dart';
import '../mocks.dart';
void main() {
late RepositoryMocks mocks;
late DevicePermissionService sut;
void stubPermissions(Map<DevicePermission, DevicePermissionStatus> statuses) {
DevicePermissionStatus answer(Invocation i) => statuses[i.positionalArguments.first] ?? .denied;
when(mocks.permission.getStatus).thenAnswer((i) async => answer(i));
when(mocks.permission.request).thenAnswer((i) async => answer(i));
}
setUp(() {
debugDefaultTargetPlatformOverride = .android;
mocks = .new();
sut = .new(mocks.permission.repo);
});
tearDown(() {
debugDefaultTargetPlatformOverride = null;
});
group('DevicePermissionService', () {
group('Gallery', () {
test('is denied when media location is missing but photos and videos are granted', () async {
when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 34);
stubPermissions({.photos: .granted, .videos: .granted, .mediaLocation: .denied});
expect(await sut.galleryStatus(), DevicePermissionStatus.denied);
verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1);
});
test('is granted when media location is granted', () async {
when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 34);
stubPermissions({.photos: .granted, .videos: .granted, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.granted);
verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1);
});
test('checks media location on the legacy storage path', () async {
when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 30);
stubPermissions({.storage: .granted, .mediaLocation: .denied});
expect(await sut.galleryStatus(), DevicePermissionStatus.denied);
verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1);
});
test('ignores media location below SDK 29', () async {
when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 28);
stubPermissions({.storage: .granted, .mediaLocation: .denied});
expect(await sut.galleryStatus(), DevicePermissionStatus.granted);
verifyNever(() => mocks.permission.repo.getStatus(.mediaLocation));
});
test('ignores media location on iOS', () async {
debugDefaultTargetPlatformOverride = .iOS;
stubPermissions({.photos: .granted, .mediaLocation: .denied});
expect(await sut.galleryStatus(), DevicePermissionStatus.granted);
verifyNever(() => mocks.permission.repo.getStatus(.mediaLocation));
});
test('is limited when only one of photos and videos is limited', () async {
stubPermissions({.photos: .granted, .videos: .limited, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.limited);
stubPermissions({.photos: .limited, .videos: .granted, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.limited);
stubPermissions({.photos: .limited, .videos: .limited, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.limited);
});
test('is denied when only one of photos and videos is denied', () async {
stubPermissions({.photos: .granted, .videos: .denied, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.denied);
stubPermissions({.photos: .denied, .videos: .granted, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.denied);
stubPermissions({.photos: .limited, .videos: .denied, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.denied);
});
test('is permanently denied when only one of photos and videos is permanently denied', () async {
stubPermissions({.photos: .granted, .videos: .permanentlyDenied, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.permanentlyDenied);
stubPermissions({.photos: .permanentlyDenied, .videos: .denied, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.permanentlyDenied);
stubPermissions({.photos: .permanentlyDenied, .videos: .granted, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.permanentlyDenied);
});
test('checks media location when access is only partial', () async {
stubPermissions({.photos: .limited, .videos: .limited, .mediaLocation: .denied});
expect(await sut.galleryStatus(), DevicePermissionStatus.denied);
verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1);
});
test('stays limited when media location is granted, but photos or video is limited', () async {
stubPermissions({.photos: .granted, .videos: .limited, .mediaLocation: .granted});
expect(await sut.galleryStatus(), DevicePermissionStatus.limited);
});
});
test('does not ask for videos when photos was refused', () async {
stubPermissions({.photos: .permanentlyDenied});
expect(await sut.requestGallery(), DevicePermissionStatus.permanentlyDenied);
verifyNever(() => mocks.permission.repo.request(.videos));
});
});
}