Skip to content

Commit 70711b3

Browse files
authored
Request the READ_MEDIA_AUDIO permission (#84)
* Request the READ_MEDIA_AUDIO permission On Android 13, READ_EXTERNAL_STORAGE has been split into more granular permissions, like READ_MEDIA_AUDIO. We need to request this permission to be able to query music from the content provider service. The READ_MEDIA_AUDIO will automatically be granted when requested if READ_EXTERNAL_STORAGE was previously granted. * Update tests for new permission and add one to test Android 13+ * Move tester.runAsync into setUpAppTest to enforce that it is called * Fix golden test * Add test for behaviour on versions before Android 13 * Only check for the required permission for the current API level * Update changelog * Update version
1 parent 1f106b8 commit 70711b3

File tree

13 files changed

+160
-105
lines changed

13 files changed

+160
-105
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
[@Abestanis]: https://github.com/Abestanis
22

3+
## 1.0.9
4+
5+
- [Fix being unable to request permissions to view audio files on Android 13 or newer](https://github.com/nt4f04uNd/sweyer/pull/84) **([@Abestanis])**
6+
37
## 1.0.8
48

59
All the work related to this version can be found in this [project](https://github.com/users/nt4f04uNd/projects/4/views/1)

android/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package="com.nt4f04und.sweyer">
33

44
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
5+
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
56
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
67
<!-- Needed to stay awake feature in MediaPlayer -->
78
<uses-permission android:name="android.permission.WAKE_LOCK" />

lib/logic/device_info.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ class DeviceInfoControl extends Control {
1515
/// starting from API 29.
1616
bool get useScopedStorageForFileModifications => sdkInt >= 30;
1717

18+
/// Whether to use the more granular audio permission (READ_MEDIA_AUDIO).
19+
///
20+
/// This must be used instead of the storage permission on API level 33 and onward.
21+
bool get useAudioPermission => sdkInt >= 33;
22+
1823
@override
1924
Future<void> init() async {
2025
super.init();

lib/logic/permissions.dart

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,29 @@ import 'package:permission_handler/permission_handler.dart';
55
class Permissions {
66
static Permissions instance = Permissions();
77

8-
/// Whether storage permission is granted
9-
late PermissionStatus _permissionStorageStatus;
8+
/// Whether audio access permission is granted
9+
late PermissionStatus _permissionAudioStatus;
1010

1111
/// Returns true if permissions were granted
12-
bool get granted => _permissionStorageStatus == PermissionStatus.granted;
12+
bool get granted => _permissionAudioStatus == PermissionStatus.granted;
1313

1414
/// Returns true if permissions were not granted
1515
bool get notGranted => !granted;
1616

17+
/// The permission to use when requesting access to the audio files on the device
18+
Permission get _audioPermission =>
19+
DeviceInfoControl.instance.useAudioPermission ? Permission.audio : Permission.storage;
20+
1721
Future<void> init() async {
18-
_permissionStorageStatus = await Permission.storage.status;
22+
_permissionAudioStatus = await _audioPermission.status;
1923
}
2024

2125
Future<void> requestClick() async {
22-
_permissionStorageStatus = await Permission.storage.request();
23-
if (_permissionStorageStatus == PermissionStatus.granted) {
26+
final responses = await [Permission.storage, Permission.audio].request();
27+
_permissionAudioStatus = responses[_audioPermission] ?? PermissionStatus.denied;
28+
if (granted) {
2429
await ContentControl.instance.init();
25-
} else if (_permissionStorageStatus == PermissionStatus.permanentlyDenied) {
30+
} else if (_permissionAudioStatus == PermissionStatus.permanentlyDenied) {
2631
final l10n = staticl10n;
2732
await ShowFunctions.instance.showToast(
2833
msg: l10n.allowAccessToExternalStorageManually,

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ homepage: https://github.com/nt4f04uNd/sweyer
44
repository: https://github.com/nt4f04uNd/sweyer
55
issue_tracker: https://github.com/nt4f04uNd/sweyer/issues
66
publish_to: none
7-
version: 1.0.8+10
7+
version: 1.0.9+11
88

99
environment:
1010
sdk: '>=2.17.0 <3.0.0'

test/fakes/fake_just_audio.dart

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,24 @@
99
import 'dart:async';
1010

1111
import 'package:flutter/services.dart';
12+
import 'package:flutter_test/flutter_test.dart';
1213
import 'package:just_audio/just_audio.dart';
1314
import 'package:just_audio_platform_interface/just_audio_platform_interface.dart';
1415
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
1516

1617
class MockJustAudio with MockPlatformInterfaceMixin implements JustAudioPlatform {
1718
MockAudioPlayer? mostRecentPlayer;
1819
final _players = <String, MockAudioPlayer>{};
20+
final TestWidgetsFlutterBinding binding;
21+
22+
MockJustAudio(this.binding);
1923

2024
@override
2125
Future<AudioPlayerPlatform> init(InitRequest request) async {
2226
if (_players.containsKey(request.id)) {
2327
throw PlatformException(code: "error", message: "Platform player ${request.id} already exists");
2428
}
25-
final player = MockAudioPlayer(request);
29+
final player = MockAudioPlayer(request, binding);
2630
_players[request.id] = player;
2731
mostRecentPlayer = player;
2832
return player;
@@ -100,8 +104,9 @@ class MockAudioPlayer implements AudioPlayerPlatform {
100104
var _speed = 1.0;
101105
Completer<dynamic>? _playCompleter;
102106
Timer? _playTimer;
107+
final TestWidgetsFlutterBinding binding;
103108

104-
MockAudioPlayer(InitRequest request)
109+
MockAudioPlayer(InitRequest request, this.binding)
105110
: _id = request.id,
106111
audioLoadConfiguration = request.audioLoadConfiguration;
107112

@@ -137,8 +142,10 @@ class MockAudioPlayer implements AudioPlayerPlatform {
137142
}
138143
_audioSource = audioSource;
139144
_index = request.initialIndex ?? 0;
140-
// Simulate loading time.
141-
await Future<dynamic>.delayed(const Duration(milliseconds: 100));
145+
if (binding.inTest) {
146+
// Simulate loading time.
147+
await Future<void>.delayed(const Duration(milliseconds: 100));
148+
}
142149
_setPosition(request.initialPosition ?? Duration.zero);
143150
_processingState = ProcessingStateMessage.ready;
144151
_broadcastPlaybackEvent();

test/golden/routes_golden_test.dart

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ void main() {
1515
await setUpAppTest(() {
1616
permissionsObserver = PermissionsChannelObserver(tester.binding);
1717
permissionsObserver.setPermission(Permission.storage, PermissionStatus.denied);
18+
permissionsObserver.setPermission(Permission.audio, PermissionStatus.denied);
1819
});
1920
await tester.runAppTest(() async {
2021
await tester.tap(find.text(l10n.grant));
@@ -112,13 +113,11 @@ void main() {
112113

113114
testAppGoldens('selection_deletion_dialog_songs_tab', (WidgetTester tester) async {
114115
final List<Song> songs = List.unmodifiable(List.generate(10, (index) => songWith(id: index)));
115-
await tester.runAsync(() async {
116-
await setUpAppTest(() {
117-
final fake = FakeDeviceInfoControl();
118-
DeviceInfoControl.instance = fake;
119-
fake.sdkInt = 29;
120-
FakeContentChannel.instance.songs = songs.toList();
121-
});
116+
await setUpAppTest(() {
117+
final fake = FakeDeviceInfoControl();
118+
DeviceInfoControl.instance = fake;
119+
fake.sdkInt = 29;
120+
FakeContentChannel.instance.songs = songs.toList();
122121
});
123122
await tester.runAppTest(() async {
124123
await tester.pumpAndSettle();
@@ -133,17 +132,15 @@ void main() {
133132

134133
group('persistent_queue_route', () {
135134
testAppGoldens('album_route', (WidgetTester tester) async {
136-
await tester.runAsync(() async {
137-
await setUpAppTest(() {
138-
FakeContentChannel.instance.songs = [
139-
songWith(id: 0, track: null, title: 'Null Song 1'),
140-
songWith(id: 5, track: null, title: 'Null Song 2'),
141-
songWith(id: 3, track: '1', title: 'First Song'),
142-
songWith(id: 2, track: '2', title: 'Second Song'),
143-
songWith(id: 4, track: '3', title: 'Third Song'),
144-
songWith(id: 1, track: '4', title: 'Fourth Song'),
145-
];
146-
});
135+
await setUpAppTest(() {
136+
FakeContentChannel.instance.songs = [
137+
songWith(id: 0, track: null, title: 'Null Song 1'),
138+
songWith(id: 5, track: null, title: 'Null Song 2'),
139+
songWith(id: 3, track: '1', title: 'First Song'),
140+
songWith(id: 2, track: '2', title: 'Second Song'),
141+
songWith(id: 4, track: '3', title: 'Third Song'),
142+
songWith(id: 1, track: '4', title: 'Fourth Song'),
143+
];
147144
});
148145
await tester.runAppTest(() async {
149146
HomeRouter.instance.goto(HomeRoutes.factory.content(albumWith()));
@@ -306,15 +303,13 @@ void main() {
306303
final localFavoriteButNotInMediaStoreSong = songWith(id: 2, title: 'Local only favorite');
307304
final mediaStoreFavoriteButNotLocalSong =
308305
songWith(id: 3, isFavoriteInMediaStore: true, title: 'MediaStore only favorite');
309-
await tester.runAsync(() async {
310-
await setUpAppTest(() async {
311-
FakeContentChannel.instance.songs = [
312-
songWith(),
313-
localFavoriteAndMediaStoreSong,
314-
localFavoriteButNotInMediaStoreSong,
315-
mediaStoreFavoriteButNotLocalSong,
316-
];
317-
});
306+
await setUpAppTest(() {
307+
FakeContentChannel.instance.songs = [
308+
songWith(),
309+
localFavoriteAndMediaStoreSong,
310+
localFavoriteButNotInMediaStoreSong,
311+
mediaStoreFavoriteButNotLocalSong,
312+
];
318313
});
319314
await tester.runAppTest(
320315
() async {

test/logic/player/favorites_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ void main() {
1111
final favoriteSong3 = songWith(id: 5, title: 'Song 5', isFavoriteInMediaStore: true);
1212

1313
setUp(() async {
14-
await setUpAppTest(() async {
14+
await setUpAppTest(() {
1515
FakeContentChannel.instance.songs = [
1616
notFavoriteSong1,
1717
notFavoriteSong2,

test/observer/permissions.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ class PermissionsChannelObserver {
3939
for (final int permissionValue in List<int>.from(call.arguments)) {
4040
final permission = Permission.byValue(permissionValue);
4141
_requestedPermissions.add(permission);
42+
}
43+
for (final int permissionValue in List<int>.from(call.arguments)) {
44+
final permission = Permission.byValue(permissionValue);
4245
final permissionStatus = await getStatus(permission);
4346
permissionStatuses[permissionValue] = permissionStatus.index;
4447
}

test/routes/home_route_test.dart

Lines changed: 84 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -13,53 +13,94 @@ void main() {
1313
await setUpAppTest();
1414
});
1515

16-
testWidgets('permissions screen - shows when are no permissions and pressing the button requests permissions',
17-
(WidgetTester tester) async {
18-
late PermissionsChannelObserver permissionsObserver;
19-
await setUpAppTest(() {
20-
permissionsObserver = PermissionsChannelObserver(tester.binding);
21-
permissionsObserver.setPermission(Permission.storage, PermissionStatus.denied);
16+
group('permissions screen', () {
17+
testWidgets('shows if no permissions were granted and pressing the button requests permissions',
18+
(WidgetTester tester) async {
19+
late PermissionsChannelObserver permissionsObserver;
20+
await setUpAppTest(() {
21+
permissionsObserver = PermissionsChannelObserver(tester.binding);
22+
permissionsObserver.setPermission(Permission.storage, PermissionStatus.denied);
23+
permissionsObserver.setPermission(Permission.audio, PermissionStatus.denied);
24+
});
25+
await tester.runAppTest(() async {
26+
expect(permissionsObserver.checkedPermissions, [Permission.storage],
27+
reason: 'Should always check the storage and audio permission on startup');
28+
expect(find.byType(Home), findsNothing, reason: 'Permissions are not granted yet');
29+
final permissionGrantCompleter = Completer<PermissionStatus>();
30+
permissionsObserver.setPermissionResolvable(Permission.storage, () => permissionGrantCompleter.future);
31+
await tester.tap(find.text(l10n.grant));
32+
expect(permissionsObserver.requestedPermissions, [Permission.storage, Permission.audio]);
33+
await tester.pump();
34+
expect(find.byType(CircularProgressIndicator), findsOneWidget,
35+
reason: 'Indicate while waiting for the permission to be granted');
36+
permissionGrantCompleter.complete(PermissionStatus.granted);
37+
await tester.pumpAndSettle();
38+
expect(find.byType(Home), findsOneWidget);
39+
});
2240
});
23-
await tester.runAppTest(() async {
24-
expect(permissionsObserver.checkedPermissions, [Permission.storage],
25-
reason: 'Should always check the storage permission on startup');
26-
expect(find.byType(Home), findsNothing, reason: 'Permissions are not granted yet');
27-
final permissionGrantCompleter = Completer<PermissionStatus>();
28-
permissionsObserver.setPermissionResolvable(Permission.storage, () => permissionGrantCompleter.future);
29-
await tester.tap(find.text(l10n.grant));
30-
expect(permissionsObserver.requestedPermissions, [Permission.storage]);
31-
await tester.pump();
32-
expect(find.byType(CircularProgressIndicator), findsOneWidget,
33-
reason: 'Indicate while waiting for the permission to be granted');
34-
permissionGrantCompleter.complete(PermissionStatus.granted);
35-
await tester.pumpAndSettle();
36-
expect(find.byType(Home), findsOneWidget);
41+
42+
/// On Android 13+ the storage permission is removed and reports as permanentlyDenied.
43+
/// If the user granted audio permissions, we have access to the music
44+
/// and don't want to show the permission request screen.
45+
testWidgets('does not show when removed storage permission is permanently denied', (WidgetTester tester) async {
46+
late PermissionsChannelObserver permissionsObserver;
47+
await setUpAppTest(() {
48+
FakeDeviceInfoControl.instance.sdkInt = 33;
49+
permissionsObserver = PermissionsChannelObserver(tester.binding);
50+
permissionsObserver.setPermission(Permission.storage, PermissionStatus.permanentlyDenied);
51+
permissionsObserver.setPermission(Permission.audio, PermissionStatus.granted);
52+
});
53+
await tester.runAppTest(() async {
54+
expect(Permissions.instance.granted, true);
55+
expect(permissionsObserver.checkedPermissions, [Permission.audio]);
56+
expect(find.byType(Home), findsOneWidget, reason: 'Audio permissions was already granted');
57+
});
3758
});
38-
});
3959

40-
testWidgets('permissions screen - shows toast and opens settings when permissions are denied',
41-
(WidgetTester tester) async {
42-
late PermissionsChannelObserver permissionsObserver;
43-
await setUpAppTest(() {
44-
permissionsObserver = PermissionsChannelObserver(tester.binding);
45-
permissionsObserver.setPermission(Permission.storage, PermissionStatus.denied);
60+
/// The audio permission is new in Android 13 and reports as permanentlyDenied on earlier versions.
61+
/// If the user granted storage permissions, we have access to the music
62+
/// and don't want to show the permission request screen.
63+
testWidgets('does not show when non-existent audio permission is permanently denied', (WidgetTester tester) async {
64+
late PermissionsChannelObserver permissionsObserver;
65+
await setUpAppTest(() {
66+
FakeDeviceInfoControl.instance.sdkInt = 32;
67+
permissionsObserver = PermissionsChannelObserver(tester.binding);
68+
permissionsObserver.setPermission(Permission.storage, PermissionStatus.granted);
69+
permissionsObserver.setPermission(Permission.audio, PermissionStatus.permanentlyDenied);
70+
});
71+
await tester.runAppTest(() async {
72+
expect(Permissions.instance.granted, true);
73+
expect(permissionsObserver.checkedPermissions, [Permission.storage]);
74+
expect(find.byType(Home), findsOneWidget, reason: 'Storage permissions was already granted');
75+
});
4676
});
47-
await tester.runAppTest(() async {
48-
permissionsObserver.setPermission(Permission.storage, PermissionStatus.permanentlyDenied);
49-
permissionsObserver.isOpeningSettingsSuccessful = false;
50-
final ToastChannelObserver toastObserver = ToastChannelObserver(tester);
51-
await tester.tap(find.text(l10n.grant));
52-
expect(permissionsObserver.openSettingsRequests, 1);
53-
expect(toastObserver.toastMessagesLog, [l10n.allowAccessToExternalStorageManually, l10n.openAppSettingsError]);
54-
55-
permissionsObserver.isOpeningSettingsSuccessful = true;
56-
await tester.tap(find.text(l10n.grant));
57-
expect(permissionsObserver.openSettingsRequests, 2);
58-
expect(toastObserver.toastMessagesLog, [
59-
l10n.allowAccessToExternalStorageManually,
60-
l10n.openAppSettingsError,
61-
l10n.allowAccessToExternalStorageManually
62-
]);
77+
78+
testWidgets('shows toast and opens settings when permissions are denied', (WidgetTester tester) async {
79+
late PermissionsChannelObserver permissionsObserver;
80+
await setUpAppTest(() {
81+
permissionsObserver = PermissionsChannelObserver(tester.binding);
82+
permissionsObserver.setPermission(Permission.storage, PermissionStatus.denied);
83+
permissionsObserver.setPermission(Permission.audio, PermissionStatus.denied);
84+
});
85+
await tester.runAppTest(() async {
86+
permissionsObserver.setPermission(Permission.storage, PermissionStatus.permanentlyDenied);
87+
permissionsObserver.setPermission(Permission.audio, PermissionStatus.permanentlyDenied);
88+
permissionsObserver.isOpeningSettingsSuccessful = false;
89+
final ToastChannelObserver toastObserver = ToastChannelObserver(tester);
90+
await tester.tap(find.text(l10n.grant));
91+
await tester.pumpAndSettle();
92+
expect(permissionsObserver.openSettingsRequests, 1);
93+
expect(toastObserver.toastMessagesLog, [l10n.allowAccessToExternalStorageManually, l10n.openAppSettingsError]);
94+
95+
permissionsObserver.isOpeningSettingsSuccessful = true;
96+
await tester.tap(find.text(l10n.grant));
97+
expect(permissionsObserver.openSettingsRequests, 2);
98+
expect(toastObserver.toastMessagesLog, [
99+
l10n.allowAccessToExternalStorageManually,
100+
l10n.openAppSettingsError,
101+
l10n.allowAccessToExternalStorageManually
102+
]);
103+
});
63104
});
64105
});
65106

0 commit comments

Comments
 (0)