diff --git a/packages/path_provider/CHANGELOG.md b/packages/path_provider/CHANGELOG.md index 3bab2b1b53bf..af0ec9f7ca0d 100644 --- a/packages/path_provider/CHANGELOG.md +++ b/packages/path_provider/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.4.0 + +* Support retrieving storage paths on Android devices with multiple external + storage options. This adds a new class `AndroidEnvironment` that shadows the + directory names from Androids `android.os.Environment` class. +* Fixes `getLibraryDirectory` semantics & tests. + ## 1.3.1 * Define clang module for iOS. diff --git a/packages/path_provider/android/build.gradle b/packages/path_provider/android/build.gradle index 191930fee299..dbbe3b804b57 100644 --- a/packages/path_provider/android/build.gradle +++ b/packages/path_provider/android/build.gradle @@ -45,3 +45,8 @@ android { disable 'InvalidPackage' } } + +dependencies { + implementation 'androidx.annotation:annotation:1.1.0' + testImplementation 'junit:junit:4.12' +} diff --git a/packages/path_provider/android/src/main/java/io/flutter/plugins/pathprovider/PathProviderPlugin.java b/packages/path_provider/android/src/main/java/io/flutter/plugins/pathprovider/PathProviderPlugin.java index 271236be060a..30b00d0e5532 100644 --- a/packages/path_provider/android/src/main/java/io/flutter/plugins/pathprovider/PathProviderPlugin.java +++ b/packages/path_provider/android/src/main/java/io/flutter/plugins/pathprovider/PathProviderPlugin.java @@ -4,6 +4,9 @@ package io.flutter.plugins.pathprovider; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import androidx.annotation.NonNull; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; @@ -11,8 +14,11 @@ import io.flutter.plugin.common.PluginRegistry.Registrar; import io.flutter.util.PathUtils; import java.io.File; +import java.util.ArrayList; +import java.util.List; public class PathProviderPlugin implements MethodCallHandler { + private final Registrar mRegistrar; public static void registerWith(Registrar registrar) { @@ -27,7 +33,7 @@ private PathProviderPlugin(Registrar registrar) { } @Override - public void onMethodCall(MethodCall call, Result result) { + public void onMethodCall(MethodCall call, @NonNull Result result) { switch (call.method) { case "getTemporaryDirectory": result.success(getPathProviderTemporaryDirectory()); @@ -38,6 +44,14 @@ public void onMethodCall(MethodCall call, Result result) { case "getStorageDirectory": result.success(getPathProviderStorageDirectory()); break; + case "getExternalCacheDirectories": + result.success(getPathProviderExternalCacheDirectories()); + break; + case "getExternalStorageDirectories": + final Integer type = call.argument("type"); + final String directoryName = StorageDirectoryMapper.androidType(type); + result.success(getPathProviderExternalStorageDirectories(directoryName)); + break; case "getApplicationSupportDirectory": result.success(getApplicationSupportDirectory()); break; @@ -65,4 +79,42 @@ private String getPathProviderStorageDirectory() { } return dir.getAbsolutePath(); } + + private List getPathProviderExternalCacheDirectories() { + final List paths = new ArrayList<>(); + + if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) { + for (File dir : mRegistrar.context().getExternalCacheDirs()) { + if (dir != null) { + paths.add(dir.getAbsolutePath()); + } + } + } else { + File dir = mRegistrar.context().getExternalCacheDir(); + if (dir != null) { + paths.add(dir.getAbsolutePath()); + } + } + + return paths; + } + + private List getPathProviderExternalStorageDirectories(String type) { + final List paths = new ArrayList<>(); + + if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) { + for (File dir : mRegistrar.context().getExternalFilesDirs(type)) { + if (dir != null) { + paths.add(dir.getAbsolutePath()); + } + } + } else { + File dir = mRegistrar.context().getExternalFilesDir(type); + if (dir != null) { + paths.add(dir.getAbsolutePath()); + } + } + + return paths; + } } diff --git a/packages/path_provider/android/src/main/java/io/flutter/plugins/pathprovider/StorageDirectoryMapper.java b/packages/path_provider/android/src/main/java/io/flutter/plugins/pathprovider/StorageDirectoryMapper.java new file mode 100644 index 000000000000..820509ba86ea --- /dev/null +++ b/packages/path_provider/android/src/main/java/io/flutter/plugins/pathprovider/StorageDirectoryMapper.java @@ -0,0 +1,51 @@ +package io.flutter.plugins.pathprovider; + +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Environment; + +/** Helps to map the Dart `StorageDirectory` enum to a Android system constant. */ +class StorageDirectoryMapper { + + /** + * Return a Android Environment constant for a Dart Index. + * + * @return The correct Android Environment constant or null, if the index is null. + * @throws IllegalArgumentException If `dartIndex` is not null but also not matches any known + * index. + */ + static String androidType(Integer dartIndex) throws IllegalArgumentException { + if (dartIndex == null) { + return null; + } + + switch (dartIndex) { + case 0: + return Environment.DIRECTORY_MUSIC; + case 1: + return Environment.DIRECTORY_PODCASTS; + case 2: + return Environment.DIRECTORY_RINGTONES; + case 3: + return Environment.DIRECTORY_ALARMS; + case 4: + return Environment.DIRECTORY_NOTIFICATIONS; + case 5: + return Environment.DIRECTORY_PICTURES; + case 6: + return Environment.DIRECTORY_MOVIES; + case 7: + return Environment.DIRECTORY_DOWNLOADS; + case 8: + return Environment.DIRECTORY_DCIM; + case 9: + if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) { + return Environment.DIRECTORY_DOCUMENTS; + } else { + throw new IllegalArgumentException("Documents directory is unsupported."); + } + default: + throw new IllegalArgumentException("Unknown index: " + dartIndex); + } + } +} diff --git a/packages/path_provider/android/src/test/java/io/flutter/plugins/pathprovider/StorageDirectoryMapperTest.java b/packages/path_provider/android/src/test/java/io/flutter/plugins/pathprovider/StorageDirectoryMapperTest.java new file mode 100644 index 000000000000..51695cf623aa --- /dev/null +++ b/packages/path_provider/android/src/test/java/io/flutter/plugins/pathprovider/StorageDirectoryMapperTest.java @@ -0,0 +1,40 @@ +package io.flutter.plugins.pathprovider; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; + +import android.os.Environment; +import org.junit.Test; + +public class StorageDirectoryMapperTest { + + @org.junit.Test + public void testAndroidType_null() { + assertNull(StorageDirectoryMapper.androidType(null)); + } + + @org.junit.Test + public void testAndroidType_valid() { + assertEquals(Environment.DIRECTORY_MUSIC, StorageDirectoryMapper.androidType(0)); + assertEquals(Environment.DIRECTORY_PODCASTS, StorageDirectoryMapper.androidType(1)); + assertEquals(Environment.DIRECTORY_MUSIC, StorageDirectoryMapper.androidType(2)); + assertEquals(Environment.DIRECTORY_RINGTONES, StorageDirectoryMapper.androidType(3)); + assertEquals(Environment.DIRECTORY_ALARMS, StorageDirectoryMapper.androidType(4)); + assertEquals(Environment.DIRECTORY_NOTIFICATIONS, StorageDirectoryMapper.androidType(5)); + assertEquals(Environment.DIRECTORY_PICTURES, StorageDirectoryMapper.androidType(6)); + assertEquals(Environment.DIRECTORY_MOVIES, StorageDirectoryMapper.androidType(7)); + assertEquals(Environment.DIRECTORY_DOWNLOADS, StorageDirectoryMapper.androidType(8)); + assertEquals(Environment.DIRECTORY_DCIM, StorageDirectoryMapper.androidType(9)); + } + + @Test + public void testAndroidType_invalid() { + try { + assertEquals(Environment.DIRECTORY_DCIM, StorageDirectoryMapper.androidType(10)); + fail(); + } catch (IllegalArgumentException e) { + assertEquals("Unknown index: " + 10, e.getMessage()); + } + } +} diff --git a/packages/path_provider/example/android/gradle.properties b/packages/path_provider/example/android/gradle.properties index 8bd86f680510..7be3d8b46841 100644 --- a/packages/path_provider/example/android/gradle.properties +++ b/packages/path_provider/example/android/gradle.properties @@ -1 +1,2 @@ org.gradle.jvmargs=-Xmx1536M +android.enableR8=true diff --git a/packages/path_provider/example/lib/main.dart b/packages/path_provider/example/lib/main.dart index 2e9e9513f787..ea12999bf415 100644 --- a/packages/path_provider/example/lib/main.dart +++ b/packages/path_provider/example/lib/main.dart @@ -39,6 +39,8 @@ class _MyHomePageState extends State { Future _appLibraryDirectory; Future _appDocumentsDirectory; Future _externalDocumentsDirectory; + Future> _externalStorageDirectories; + Future> _externalCacheDirectories; void _requestTempDirectory() { setState(() { @@ -61,6 +63,23 @@ class _MyHomePageState extends State { return Padding(padding: const EdgeInsets.all(16.0), child: text); } + Widget _buildDirectories( + BuildContext context, AsyncSnapshot> snapshot) { + Text text = const Text(''); + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasError) { + text = Text('Error: ${snapshot.error}'); + } else if (snapshot.hasData) { + final String combined = + snapshot.data.map((Directory d) => d.path).join(', '); + text = Text('paths: $combined'); + } else { + text = const Text('path unavailable'); + } + } + return Padding(padding: const EdgeInsets.all(16.0), child: text); + } + void _requestAppDocumentsDirectory() { setState(() { _appDocumentsDirectory = getApplicationDocumentsDirectory(); @@ -85,6 +104,18 @@ class _MyHomePageState extends State { }); } + void _requestExternalStorageDirectories(StorageDirectory type) { + setState(() { + _externalStorageDirectories = getExternalStorageDirectories(type: type); + }); + } + + void _requestExternalCacheDirectories() { + setState(() { + _externalCacheDirectories = getExternalCacheDirectories(); + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -140,7 +171,39 @@ class _MyHomePageState extends State { ), ), FutureBuilder( - future: _externalDocumentsDirectory, builder: _buildDirectory) + future: _externalDocumentsDirectory, builder: _buildDirectory), + Column(children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: RaisedButton( + child: Text( + '${Platform.isIOS ? "External directories are unavailable " "on iOS" : "Get External Storage Directories"}'), + onPressed: Platform.isIOS + ? null + : () { + _requestExternalStorageDirectories( + StorageDirectory.music, + ); + }, + ), + ), + ]), + FutureBuilder>( + future: _externalStorageDirectories, + builder: _buildDirectories), + Column(children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: RaisedButton( + child: Text( + '${Platform.isIOS ? "External directories are unavailable " "on iOS" : "Get External Cache Directories"}'), + onPressed: + Platform.isIOS ? null : _requestExternalCacheDirectories, + ), + ), + ]), + FutureBuilder>( + future: _externalCacheDirectories, builder: _buildDirectories), ], ), ), diff --git a/packages/path_provider/example/pubspec.yaml b/packages/path_provider/example/pubspec.yaml index 9daa92b8c22e..015a4fb46e5d 100644 --- a/packages/path_provider/example/pubspec.yaml +++ b/packages/path_provider/example/pubspec.yaml @@ -6,7 +6,6 @@ dependencies: sdk: flutter path_provider: path: ../ - uuid: "^1.0.0" dev_dependencies: flutter_driver: diff --git a/packages/path_provider/example/test_driver/path_provider.dart b/packages/path_provider/example/test_driver/path_provider.dart index 8438965f9646..ccbc62599e02 100644 --- a/packages/path_provider/example/test_driver/path_provider.dart +++ b/packages/path_provider/example/test_driver/path_provider.dart @@ -8,7 +8,6 @@ import 'dart:io'; import 'package:flutter_driver/driver_extension.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:uuid/uuid.dart'; void main() { final Completer allTestsCompleter = Completer(); @@ -17,43 +16,23 @@ void main() { test('getTemporaryDirectory', () async { final Directory result = await getTemporaryDirectory(); - final String uuid = Uuid().v1(); - final File file = File('${result.path}/$uuid.txt'); - file.writeAsStringSync('Hello world!'); - expect(file.readAsStringSync(), 'Hello world!'); - expect(result.listSync(), isNotEmpty); - file.deleteSync(); + _verifySampleFile(result, 'temporaryDirectory'); }); test('getApplicationDocumentsDirectory', () async { final Directory result = await getApplicationDocumentsDirectory(); - final String uuid = Uuid().v1(); - final File file = File('${result.path}/$uuid.txt'); - file.writeAsStringSync('Hello world!'); - expect(file.readAsStringSync(), 'Hello world!'); - expect(result.listSync(), isNotEmpty); - file.deleteSync(); + _verifySampleFile(result, 'applicationDocuments'); }); test('getApplicationSupportDirectory', () async { final Directory result = await getApplicationSupportDirectory(); - final String uuid = Uuid().v1(); - final File file = File('${result.path}/$uuid.txt'); - file.writeAsStringSync('Hello world!'); - expect(file.readAsStringSync(), 'Hello world!'); - expect(result.listSync(), isNotEmpty); - file.deleteSync(); + _verifySampleFile(result, 'applicationSupport'); }); test('getLibraryDirectory', () async { if (Platform.isIOS) { final Directory result = await getLibraryDirectory(); - final String uuid = Uuid().v1(); - final File file = File('${result.path}/$uuid.txt'); - file.writeAsStringSync('Hello world!'); - expect(file.readAsStringSync(), 'Hello world!'); - expect(result.listSync(), isNotEmpty); - file.deleteSync(); + _verifySampleFile(result, 'library'); } else if (Platform.isAndroid) { final Future result = getLibraryDirectory(); expect(result, throwsA(isInstanceOf())); @@ -66,12 +45,62 @@ void main() { expect(result, throwsA(isInstanceOf())); } else if (Platform.isAndroid) { final Directory result = await getExternalStorageDirectory(); - final String uuid = Uuid().v1(); - final File file = File('${result.path}/$uuid.txt'); - file.writeAsStringSync('Hello world!'); - expect(file.readAsStringSync(), 'Hello world!'); - expect(result.listSync(), isNotEmpty); - file.deleteSync(); + _verifySampleFile(result, 'externalStorage'); + } + }); + + test('getExternalCacheDirectories', () async { + if (Platform.isIOS) { + final Future> result = getExternalCacheDirectories(); + expect(result, throwsA(isInstanceOf())); + } else if (Platform.isAndroid) { + final List directories = await getExternalCacheDirectories(); + for (Directory result in directories) { + _verifySampleFile(result, 'externalCache'); + } } }); + + final List _allDirs = [ + null, + StorageDirectory.music, + StorageDirectory.podcasts, + StorageDirectory.ringtones, + StorageDirectory.alarms, + StorageDirectory.notifications, + StorageDirectory.pictures, + StorageDirectory.movies, + ]; + + for (StorageDirectory type in _allDirs) { + test('getExternalStorageDirectories (type: $type)', () async { + if (Platform.isIOS) { + final Future> result = + getExternalStorageDirectories(type: null); + expect(result, throwsA(isInstanceOf())); + } else if (Platform.isAndroid) { + final List directories = + await getExternalStorageDirectories(type: type); + for (Directory result in directories) { + _verifySampleFile(result, '$type'); + } + } + }); + } +} + +/// Verify a file called [name] in [directory] by recreating it with test +/// contents when necessary. +void _verifySampleFile(Directory directory, String name) { + final File file = File('${directory.path}/$name'); + + if (file.existsSync()) { + file.deleteSync(); + expect(file.existsSync(), isFalse); + } + + file.writeAsStringSync('Hello world!'); + expect(file.readAsStringSync(), 'Hello world!'); + expect(directory.listSync(), isNotEmpty); + file.deleteSync(); } diff --git a/packages/path_provider/lib/path_provider.dart b/packages/path_provider/lib/path_provider.dart index f0587225c498..3840121c8c4b 100644 --- a/packages/path_provider/lib/path_provider.dart +++ b/packages/path_provider/lib/path_provider.dart @@ -61,7 +61,13 @@ Future getApplicationSupportDirectory() async { /// Path to the directory where application can store files that are persistent, /// backed up, and not visible to the user, such as sqlite.db. +/// +/// On Android, this function throws an [UnsupportedError] as no equivalent +/// path exists. Future getLibraryDirectory() async { + if (_platform.isAndroid) { + throw UnsupportedError('Functionality not available on Android'); + } final String path = await _channel.invokeMethod('getLibraryDirectory'); if (path == null) { @@ -97,8 +103,9 @@ Future getApplicationDocumentsDirectory() async { /// /// On Android this uses the `getExternalFilesDir(null)`. Future getExternalStorageDirectory() async { - if (_platform.isIOS) - throw UnsupportedError("Functionality not available on iOS"); + if (_platform.isIOS) { + throw UnsupportedError('Functionality not available on iOS'); + } final String path = await _channel.invokeMethod('getStorageDirectory'); if (path == null) { @@ -106,3 +113,70 @@ Future getExternalStorageDirectory() async { } return Directory(path); } + +/// Paths to directories where application specific external cache data can be +/// stored. These paths typically reside on external storage like separate +/// partitions or SD cards. Phones may have multiple storage directories +/// available. +/// +/// The current operating system should be determined before issuing this +/// function call, as this functionality is only available on Android. +/// +/// On iOS, this function throws an UnsupportedError as it is not possible +/// to access outside the app's sandbox. +/// +/// On Android this returns Context.getExternalCacheDirs() or +/// Context.getExternalCacheDir() on API levels below 19. +Future> getExternalCacheDirectories() async { + if (_platform.isIOS) { + throw UnsupportedError('Functionality not available on iOS'); + } + final List paths = + await _channel.invokeListMethod('getExternalCacheDirectories'); + + return paths.map((String path) => Directory(path)).toList(); +} + +/// Corresponds to constants defined in Androids `android.os.Environment` class. +/// +/// https://developer.android.com/reference/android/os/Environment.html#fields_1 +enum StorageDirectory { + music, + podcasts, + ringtones, + alarms, + notifications, + pictures, + movies, + downloads, + dcim, + documents, +} + +/// Paths to directories where application specific data can be stored. +/// These paths typically reside on external storage like separate partitions +/// or SD cards. Phones may have multiple storage directories available. +/// +/// The current operating system should be determined before issuing this +/// function call, as this functionality is only available on Android. +/// +/// On iOS, this function throws an UnsupportedError as it is not possible +/// to access outside the app's sandbox. +/// +/// On Android this returns Context.getExternalFilesDirs(String type) or +/// Context.getExternalFilesDir(String type) on API levels below 19. +Future> getExternalStorageDirectories({ + /// Optional parameter. See [StorageDirectory] for more informations on + /// how this type translates to Android storage directories. + StorageDirectory type, +}) async { + if (_platform.isIOS) { + throw UnsupportedError('Functionality not available on iOS'); + } + final List paths = await _channel.invokeListMethod( + 'getExternalStorageDirectories', + {'type': type?.index}, + ); + + return paths.map((String path) => Directory(path)).toList(); +} diff --git a/packages/path_provider/pubspec.yaml b/packages/path_provider/pubspec.yaml index 16245929b85e..4517eba48246 100644 --- a/packages/path_provider/pubspec.yaml +++ b/packages/path_provider/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for getting commonly used locations on the Android & iOS file systems, such as the temp and app data directories. author: Flutter Team homepage: https://github.com/flutter/plugins/tree/master/packages/path_provider -version: 1.3.1 +version: 1.4.0 flutter: plugin: diff --git a/packages/path_provider/test/path_provider_test.dart b/packages/path_provider/test/path_provider_test.dart index 24368c04cb8c..7af37888492f 100644 --- a/packages/path_provider/test/path_provider_test.dart +++ b/packages/path_provider/test/path_provider_test.dart @@ -15,7 +15,7 @@ void main() { const MethodChannel channel = MethodChannel('plugins.flutter.io/path_provider'); final List log = []; - String response; + dynamic response; channel.setMockMethodCallHandler((MethodCall methodCall) async { log.add(methodCall); @@ -86,8 +86,17 @@ void main() { expect(directory, isNull); }); - test('getLibraryDirectory test', () async { - response = null; + test('getLibraryDirectory Android test', () async { + try { + await getLibraryDirectory(); + fail('should throw UnsupportedError'); + } catch (e) { + expect(e, isUnsupportedError); + } + }); + test('getLibraryDirectory iOS test', () async { + setMockPathProviderPlatform(FakePlatform(operatingSystem: 'ios')); + final Directory directory = await getLibraryDirectory(); expect( log, @@ -99,7 +108,6 @@ void main() { test('getExternalStorageDirectory iOS test', () async { setMockPathProviderPlatform(FakePlatform(operatingSystem: 'ios')); - response = null; try { await getExternalStorageDirectory(); fail('should throw UnsupportedError'); @@ -108,6 +116,57 @@ void main() { } }); + test('getExternalCacheDirectories test', () async { + response = []; + final List directories = await getExternalCacheDirectories(); + expect( + log, + [isMethodCall('getExternalCacheDirectories', arguments: null)], + ); + expect(directories, []); + }); + + test('getExternalCacheDirectories iOS test', () async { + setMockPathProviderPlatform(FakePlatform(operatingSystem: 'ios')); + + try { + await getExternalCacheDirectories(); + fail('should throw UnsupportedError'); + } catch (e) { + expect(e, isUnsupportedError); + } + }); + + for (StorageDirectory type + in StorageDirectory.values + [null]) { + test('getExternalStorageDirectories test (type: $type)', () async { + response = []; + final List directories = + await getExternalStorageDirectories(type: type); + expect( + log, + [ + isMethodCall( + 'getExternalStorageDirectories', + arguments: {'type': type?.index}, + ) + ], + ); + expect(directories, []); + }); + } + + test('getExternalStorageDirectories iOS test', () async { + setMockPathProviderPlatform(FakePlatform(operatingSystem: 'ios')); + + try { + await getExternalStorageDirectories(type: StorageDirectory.music); + fail('should throw UnsupportedError'); + } catch (e) { + expect(e, isUnsupportedError); + } + }); + test('TemporaryDirectory path test', () async { final String fakePath = "/foo/bar/baz"; response = fakePath; @@ -137,6 +196,8 @@ void main() { }); test('ApplicationLibraryDirectory path test', () async { + setMockPathProviderPlatform(FakePlatform(operatingSystem: 'ios')); + final String fakePath = "/foo/bar/baz"; response = fakePath; final Directory directory = await getLibraryDirectory(); @@ -149,4 +210,20 @@ void main() { final Directory directory = await getExternalStorageDirectory(); expect(directory.path, equals(fakePath)); }); + + test('ExternalCacheDirectories path test', () async { + final List paths = ["/foo/bar/baz", "/foo/bar/baz2"]; + response = paths; + final List directories = await getExternalCacheDirectories(); + expect(directories.map((Directory d) => d.path).toList(), equals(paths)); + }); + + test('ExternalStorageDirectories path test', () async { + final List paths = ["/foo/bar/baz", "/foo/bar/baz2"]; + response = paths; + final List directories = await getExternalStorageDirectories( + type: StorageDirectory.music, + ); + expect(directories.map((Directory d) => d.path).toList(), equals(paths)); + }); }