This is going to be a long answer and a lot of the code is specific to my use case so if someone wants to reuse it, you might need to tweak things.
Basically with the changes in Android 30+ I wasn't able to get permissions to write to a directory on the user's phone that wasn't my apps own directory without requesting the dreaded manage_external_storage
.
I solved this by doing this with native Kotlin then calling those methods via an interface in Dart.
First starting with the Kotlin code
class MainActivity : FlutterActivity() {
private val CHANNEL = "package/Main"
private var pendingResult: MethodChannel.Result? = null
private var methodCall: MethodCall? = null
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
CHANNEL
).setMethodCallHandler { call, result ->
val handlers = mapOf(
"getSavedRoot" to ::getSavedRoot,
"selectDirectory" to ::copyDirectoryToCache,
"createDirectory" to ::createDirectory,
"writeFile" to ::writeFile,
)
if (call.method in handlers) {
handlers[call.method]!!.invoke(call, result)
} else {
result.notImplemented()
}
}
}
This sets up our MainActivity
to listen for methods named in the setMethodCallHandler
method.
There are plenty of examples you can find for how to implement basic IO functions in Kotlin so I won't post them all here, but an example of how to open a set a content root and handle the result:
class MainActivity : FlutterActivity() {
//...
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
private fun selectContentRoot(call: MethodCall, result: MethodChannel.Result) {
pendingResult = result
try {
val browseIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(browseIntent, 100)
} catch (e: Throwable) {
Log.e("selectDirectory", " error", e)
}
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == 100 && resultCode == RESULT_OK) {
val uri: Uri = data?.data!!
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
return pendingResult!!.success(uri.toString())
}
return
}
//..
Now to invoke that code in Dart I created an interface named AndroidInterface
and implemented
class AndroidInterface {
final _platform = const MethodChannel('package/Main');
final _errors = {
'no persist document tree': FileOperationError.noSavedPersistRoot,
'pending': FileOperationError.pending,
'access error': FileOperationError.accessError,
'exists': FileOperationError.alreadyExists,
'creation failed': FileOperationError.creationFailed,
'canceled': FileOperationError.canceled,
};
String? _root;
// invoke a method with given arguments
Future<FileOperationResult<String>> _invoke(
String method, {
bool returnVoid = false,
String? root,
String? directory,
String? subdir,
String? name,
Uint8List? bytes,
bool? overwrite,
}) async {
try {
final result = await _platform.invokeMethod<String>(method, {
'root': root,
'directory': directory,
'subdir': subdir,
'name': name,
'bytes': bytes,
'overwrite': overwrite,
});
if (result != null || returnVoid) {
final fileOperationResult = FileOperationResult(result: result);
fileOperationResult.result = result;
return fileOperationResult;
}
return FileOperationResult(error: FileOperationError.unknown);
} on PlatformException catch (e) {
final error = _errors[e.code] ?? FileOperationError.unknown;
return FileOperationResult(
error: error,
result: e.code,
message: e.message,
);
}
}
Future<FileOperationResult<String>> selectContentRoot() async {
final result = await _invoke('selectContentRoot');
// release currently selected directory if new directory selected successfully
if (result.error == FileOperationError.success) {
if (_root != null) {
await _invoke('releaseDirectory', root: _root, returnVoid: true);
}
_root = result.result;
}
return result;
}
//...
Which basically sends the requests via _platform.invokeMethod
passing the name of the method, and the arguments to send.
Using a factory pattern you can implement this interface device running 30+ and use standard stuff for Apple and devices running 29 and below.
Something like:
abstract class IOInterface {
//...
/// Select a subdirectory of the root directory
Future<void> selectDirectory(String? message, String? buttonText);
}
And a factory to decide what interface to use
class IOFactory {
static IOInterface? _interface;
static IOInterface? get instance => _interface;
IOFactory._create();
static Future<IOFactory> create() async {
final component = IOFactory._create();
if (Platform.isAndroid) {
final androidInfo = await DeviceInfoPlugin().androidInfo;
final sdkInt = androidInfo.version.sdkInt;
_interface = sdkInt > 29 ? AndroidSDKThirty() : AndroidSDKTwentyNine();
}
if (Platform.isIOS) {
_interface = AppleAll();
}
return component;
}
}
Finally, the implementation for 30+ could look like
class AndroidSDKThirty implements IOInterface {
final AndroidInterface _androidInterface = AndroidInterface();
@override
Future<void> selectDirectory(String? message, String? buttonText) async {
final contentRoot = await _androidInterface.getContentRoot();
//...
}
Hopefully, this is enough to get you started and pointed in the right direction.