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 SlideshowDirection { forward, backward, shuffle }
enum PartnerDirection { sharedBy, sharedWith } 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 NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository; final DevicePermissionRepository _permissionRepository;
final Completer<void>? _cancellation; final Completer<void>? _cancellation;
final Logger _log = Logger("DeviceSyncService"); final Logger _log = Logger("DeviceSyncService");

View file

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

View file

@ -1,109 +1,28 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:hooks_riverpod/hooks_riverpod.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> { final galleryPermissionServiceProvider = Provider((ref) {
GalleryPermissionNotifier() return DevicePermissionService(ref.watch(permissionRepositoryProvider));
: super(PermissionStatus.denied) // Denied is the initial state });
{
// Sets the initial state class GalleryPermissionNotifier extends StateNotifier<DevicePermissionStatus> {
GalleryPermissionNotifier(this._service) : super(.denied) {
unawaited(getGalleryPermissionStatus()); unawaited(getGalleryPermissionStatus());
} }
bool get hasPermission => state.isGranted || state.isLimited; final DevicePermissionService _service;
/// Requests the gallery permission bool get hasPermission => state.hasAccess;
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();
// Return the joint result of those two permissions Future<DevicePermissionStatus> requestGalleryPermission() async => state = await _service.requestGallery();
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; Future<DevicePermissionStatus> getGalleryPermissionStatus() async => state = await _service.galleryStatus();
}
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>( final galleryPermissionNotifier = StateNotifierProvider<GalleryPermissionNotifier, DevicePermissionStatus>(
(ref) => GalleryPermissionNotifier(), (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: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/platform/permission_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.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) { final permissionRepositoryProvider = Provider((ref) {
return PermissionRepository(ref.watch(permissionApiProvider)); return DevicePermissionRepository(ref.watch(permissionApiProvider));
}); });
class PermissionRepository implements IPermissionRepository { class DevicePermissionRepository {
final PermissionApi _permissionApi; final PermissionApi _permissionApi;
const PermissionRepository(this._permissionApi); const DevicePermissionRepository(this._permissionApi);
@override Future<DevicePermissionStatus> getStatus(DevicePermission permission) async =>
Future<bool> hasLocationWhenInUsePermission() { (await permission.handler.status).toDevicePermissionStatus();
return Permission.locationWhenInUse.isGranted;
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");
}
final androidInfo = await DeviceInfoPlugin().androidInfo;
return androidInfo.version.sdkInt;
} }
@override Future<bool> hasLocationWhenInUsePermission() => ph.Permission.locationWhenInUse.isGranted;
Future<bool> requestLocationWhenInUsePermission() async { Future<bool> requestLocationWhenInUsePermission() async {
final result = await Permission.locationWhenInUse.request(); final result = await ph.Permission.locationWhenInUse.request();
return result.isGranted; return result.isGranted;
} }
@override
Future<bool> hasLocationAlwaysPermission() { Future<bool> hasLocationAlwaysPermission() {
return Permission.locationAlways.isGranted; return ph.Permission.locationAlways.isGranted;
} }
@override
Future<bool> requestLocationAlwaysPermission() async { Future<bool> requestLocationAlwaysPermission() async {
final result = await Permission.locationAlways.request(); final result = await ph.Permission.locationAlways.request();
return result.isGranted; return result.isGranted;
} }
@override
Future<bool> openSettings() { Future<bool> openSettings() {
return openAppSettings(); return ph.openAppSettings();
} }
@override
Future<bool> hasManageMediaPermission() { Future<bool> hasManageMediaPermission() {
return _permissionApi.hasManageMediaPermission(); return _permissionApi.hasManageMediaPermission();
} }
@override
Future<bool> requestManageMediaPermission() { Future<bool> requestManageMediaPermission() {
return _permissionApi.requestManageMediaPermission(); return _permissionApi.requestManageMediaPermission();
} }
@override
Future<bool> manageMediaPermission() { Future<bool> manageMediaPermission() {
return _permissionApi.manageMediaPermission(); return _permissionApi.manageMediaPermission();
} }
} }
abstract interface class IPermissionRepository { extension on DevicePermission {
Future<bool> hasLocationWhenInUsePermission(); ph.Permission get handler => switch (this) {
Future<bool> requestLocationWhenInUsePermission(); .photos => ph.Permission.photos,
Future<bool> hasLocationAlwaysPermission(); .videos => ph.Permission.videos,
Future<bool> requestLocationAlwaysPermission(); .storage => ph.Permission.storage,
Future<bool> openSettings(); .mediaLocation => ph.Permission.accessMediaLocation,
Future<bool> hasManageMediaPermission(); };
Future<bool> requestManageMediaPermission(); }
Future<bool> manageMediaPermission();
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 { class NetworkService {
final NetworkRepository _repository; final NetworkRepository _repository;
final IPermissionRepository _permissionRepository; final DevicePermissionRepository _permissionRepository;
const NetworkService(this._repository, this._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 MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
class MockPermissionRepository extends Mock implements IPermissionRepository {} class MockPermissionRepository extends Mock implements DevicePermissionRepository {}
class MockAuthApiRepository extends Mock implements AuthApiRepository {} class MockAuthApiRepository extends Mock implements AuthApiRepository {}

View file

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