Skip to content

Commit 69ddbfd

Browse files
author
LaksCastro
committed
(#10, #11) Enable the SAF API to copy files
1 parent fd55674 commit 69ddbfd

File tree

14 files changed

+137
-216
lines changed

14 files changed

+137
-216
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
.svn/
1111

1212
*/**/pubspec.lock
13+
pubspec.lock
1314

1415
# IntelliJ related
1516
*.iml

android/src/main/kotlin/io/lakscastro/sharedstorage/SharedStoragePlugin.kt

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,23 @@ import io.lakscastro.sharedstorage.saf.StorageAccessFramework
1212

1313
const val ROOT_CHANNEL = "io.lakscastro.plugins/sharedstorage"
1414

15-
/** SharedStoragePlugin */
15+
/**
16+
* Flutter plugin Kotlin implementation `SharedStoragePlugin`
17+
*/
1618
class SharedStoragePlugin : FlutterPlugin, ActivityAware {
17-
/// `Environment` API channel
19+
/**
20+
* `Environment` API channel
21+
*/
1822
private val environmentApi = EnvironmentApi(this)
1923

20-
/// `MediaStore` API channel
24+
/**
25+
* `MediaStore` API channel
26+
*/
2127
private val mediaStoreApi = MediaStoreApi(this)
2228

23-
/// `DocumentFile` API channel
29+
/**
30+
* `DocumentFile` API channel
31+
*/
2432
private val storageAccessFrameworkApi = StorageAccessFramework(this)
2533

2634
lateinit var context: Context
@@ -29,13 +37,13 @@ class SharedStoragePlugin : FlutterPlugin, ActivityAware {
2937
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPluginBinding) {
3038
context = flutterPluginBinding.applicationContext
3139

32-
/// Setup `Environment` API
40+
/** Setup `Environment` API */
3341
environmentApi.startListening(flutterPluginBinding.binaryMessenger)
3442

35-
/// Setup `MediaStore` API
43+
/** Setup `MediaStore` API */
3644
mediaStoreApi.startListening(flutterPluginBinding.binaryMessenger)
3745

38-
/// Setup `StorageAccessFramework` API
46+
/** Setup `StorageAccessFramework` API */
3947
storageAccessFrameworkApi.startListening(flutterPluginBinding.binaryMessenger)
4048
}
4149

android/src/main/kotlin/io/lakscastro/sharedstorage/environment/EnvironmentApi.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ class EnvironmentApi(val plugin: SharedStoragePlugin) : MethodChannel.MethodCall
4545
}
4646
}
4747

48-
/// Deprecated Android API, use only if you know exactly what you need
48+
/**
49+
* Deprecated Android API, use only if you know exactly what you need
50+
*/
4951
private fun getExternalStoragePublicDirectory(
5052
result: MethodChannel.Result,
5153
directory: String
@@ -65,7 +67,9 @@ class EnvironmentApi(val plugin: SharedStoragePlugin) : MethodChannel.MethodCall
6567
private fun getDownloadCacheDirectory(result: MethodChannel.Result) =
6668
result.success(Environment.getDownloadCacheDirectory().absolutePath)
6769

68-
/// Deprecated Android API, use only if you know exactly what you need
70+
/**
71+
* Deprecated Android API, use only if you know exactly what you need
72+
*/
6973
private fun getExternalStorageDirectory(result: MethodChannel.Result) =
7074
result.success(Environment.getExternalStorageDirectory().absolutePath)
7175

android/src/main/kotlin/io/lakscastro/sharedstorage/mediastore/MediaStoreApi.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ class MediaStoreApi(val plugin: SharedStoragePlugin) : MethodChannel.MethodCallH
3434
collection: String
3535
) = result.success(mediaStoreOf(collection))
3636

37-
/// Returns the [EXTERNAL_CONTENT_URI] of [MediaStore.<MEDIA>] equivalent to [collection]
37+
/**
38+
* Returns the [EXTERNAL_CONTENT_URI] of [MediaStore.<MEDIA>] equivalent to [collection]
39+
*/
3840
private fun mediaStoreOf(collection: String): String? {
3941
val mapper = mutableMapOf(
4042
"MediaStoreCollection.Audio" to MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.path,

android/src/main/kotlin/io/lakscastro/sharedstorage/saf/DocumentFileApi.kt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.os.Build
66
import android.provider.DocumentsContract
77
import android.util.Log
88
import androidx.annotation.RequiresApi
9+
import androidx.documentfile.provider.DocumentFile
910
import io.flutter.plugin.common.*
1011
import io.flutter.plugin.common.EventChannel.StreamHandler
1112
import io.lakscastro.sharedstorage.ROOT_CHANNEL
@@ -16,6 +17,7 @@ import kotlinx.coroutines.CoroutineScope
1617
import kotlinx.coroutines.Dispatchers
1718
import kotlinx.coroutines.launch
1819
import java.io.BufferedReader
20+
import java.io.FileInputStream
1921
import java.io.InputStreamReader
2022

2123
internal class DocumentFileApi(private val plugin: SharedStoragePlugin) :
@@ -420,23 +422,32 @@ internal class DocumentFileApi(private val plugin: SharedStoragePlugin) :
420422
}
421423
}
422424
GET_DOCUMENT_CONTENT -> {
423-
val uri = Uri.parse(args["uri"] as String)
425+
if (Build.VERSION.SDK_INT >= API_21) {
426+
val uri = Uri.parse(args["uri"] as String)
424427

425-
readDocumentContent(uri) {
426-
onSuccess = { eventSink?.success(this) }
427-
onEnd = { eventSink?.endOfStream() }
428+
readDocumentContent(uri) {
429+
onSuccess = { eventSink?.success(this) }
430+
onEnd = { eventSink?.endOfStream() }
431+
}
432+
} else {
433+
eventSink?.endOfStream()
428434
}
429435
}
430436
}
431437
}
432438

439+
@RequiresApi(API_21)
433440
private fun readDocumentContent(
434441
uri: Uri,
435442
handler: CallbackHandler<String>.() -> Unit
436443
) {
437444
val callbacks = CallbackHandler<String>().apply { handler(this) }
438445

439-
plugin.context.contentResolver.openInputStream(uri)
446+
val document = documentFromTreeUri(plugin.context, uri)!!
447+
448+
val file = document?.createFile("text/plain", "File created by Shared Storage Sample App") ?: return
449+
450+
plugin.context.contentResolver.openInputStream(file.uri)
440451
?.use { inputStream ->
441452
BufferedReader(InputStreamReader(inputStream)).use { reader ->
442453
var line = reader.readLine()

android/src/main/kotlin/io/lakscastro/sharedstorage/saf/utils/DocumentCommon.kt

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,22 @@ import io.lakscastro.sharedstorage.plugin.API_24
1515
import java.io.ByteArrayOutputStream
1616
import java.io.Closeable
1717

18-
/*
18+
/**
1919
* Helper class to make more easy to handle callbacks using Kotlin syntax
2020
*/
2121
data class CallbackHandler<T>(
2222
var onSuccess: (T.() -> Unit)? = null,
2323
var onEnd: (() -> Unit)? = null
2424
)
2525

26-
/*
26+
/**
2727
* Generate the `DocumentFile` reference from string `uri` (Single `DocumentFile`)
2828
*/
2929
@RequiresApi(API_21)
3030
fun documentFromSingleUri(context: Context, uri: String): DocumentFile? =
3131
documentFromSingleUri(context, Uri.parse(uri))
3232

33-
/*
33+
/**
3434
* Generate the `DocumentFile` reference from string `uri` (Single `DocumentFile`)
3535
*/
3636
@RequiresApi(API_21)
@@ -43,14 +43,14 @@ fun documentFromSingleUri(context: Context, uri: Uri): DocumentFile? {
4343
return DocumentFile.fromSingleUri(context, documentUri)
4444
}
4545

46-
/*
46+
/**
4747
* Generate the `DocumentFile` reference from string `uri`
4848
*/
4949
@RequiresApi(API_21)
5050
fun documentFromTreeUri(context: Context, uri: String): DocumentFile? =
5151
documentFromTreeUri(context, Uri.parse(uri))
5252

53-
/*
53+
/**
5454
* Generate the `DocumentFile` reference from URI `uri`
5555
*/
5656
@RequiresApi(API_21)
@@ -67,7 +67,7 @@ fun documentFromTreeUri(context: Context, uri: Uri): DocumentFile? {
6767
return DocumentFile.fromTreeUri(context, treeUri)
6868
}
6969

70-
/*
70+
/**
7171
* Standard map encoding of a `DocumentFile` and must be used before returning any `DocumentFile`
7272
* from plugin results, like:
7373
* ```dart
@@ -89,7 +89,7 @@ fun createDocumentFileMap(documentFile: DocumentFile?): Map<String, Any?>? {
8989
}
9090

9191

92-
/*
92+
/**
9393
* Standard map encoding of a row result of a `DocumentFile`
9494
* ```dart
9595
* result.success(createDocumentFileMap(documentFile))
@@ -140,7 +140,7 @@ fun createCursorRowMap(
140140
)
141141
}
142142

143-
/*
143+
/**
144144
* Util method to close a closeable
145145
*/
146146
fun closeQuietly(closeable: Closeable?) {
@@ -205,7 +205,12 @@ fun traverseDirectoryEntries(
205205

206206
val isDirectory = if (mimeType != null) isDirectory(mimeType) else null
207207

208-
val uri = DocumentsContract.buildDocumentUri(rootUri.authority, id)
208+
val uri = DocumentsContract.buildDocumentUriUsingTree(
209+
parent,
210+
DocumentsContract.getDocumentId(parent)
211+
)
212+
213+
Uri.Builder().scheme(uri.scheme)
209214

210215
block(createCursorRowMap(rootUri, parent, uri, data, isDirectory = isDirectory))
211216

@@ -237,7 +242,7 @@ fun bitmapToBase64(bitmap: Bitmap): String {
237242
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
238243
}
239244

240-
/*
245+
/**
241246
* Trick to verify if is a tree URi even not in API 26+
242247
*/
243248
fun isTreeUri(uri: Uri): Boolean {

android/src/main/kotlin/io/lakscastro/sharedstorage/saf/utils/DocumentConstant.kt

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package io.lakscastro.sharedstorage.saf.utils
22

3-
/// Storage Access Framework Exceptions
3+
/**
4+
* Storage Access Framework Exceptions
5+
*/
46
const val EXCEPTION_PARENT_DOCUMENT_MUST_BE_DIRECTORY =
57
"EXCEPTION_PARENT_DOCUMENT_MUST_BE_DIRECTORY"
68
const val EXCEPTION_MISSING_PERMISSIONS = "EXCEPTION_MISSING_PERMISSIONS"
79

8-
/// Available Method Channel APIs
10+
/**
11+
* Available Method Channel APIs
12+
*/
913
const val OPEN_DOCUMENT_TREE = "openDocumentTree"
1014
const val PERSISTED_URI_PERMISSIONS = "persistedUriPermissions"
1115
const val RELEASE_PERSISTABLE_URI_PERMISSION =
@@ -27,10 +31,14 @@ const val GET_DOCUMENT_THUMBNAIL = "getDocumentThumbnail"
2731
const val BUILD_DOCUMENT_URI_USING_TREE = "buildDocumentUriUsingTree"
2832
const val BUILD_DOCUMENT_URI = "buildDocumentUri"
2933
const val BUILD_TREE_DOCUMENT_URI = "buildTreeDocumentUri"
30-
const val GET_DOCUMENT_CONTENT = "getDocumentContent"
3134

32-
/// Available Event Channels APIs
35+
/**
36+
* Available Event Channels APIs
37+
*/
3338
const val LIST_FILES = "listFiles"
39+
const val GET_DOCUMENT_CONTENT = "getDocumentContent"
3440

35-
/// Intent Request Codes
41+
/**
42+
* Intent Request Codes
43+
*/
3644
const val OPEN_DOCUMENT_TREE_CODE = 10

example/android/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
22
package="io.lakscastro.sharedstorage.example">
33

4+
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
45
<application
56
android:icon="@mipmap/ic_launcher"
67
android:label="Shared Storage Example">

example/lib/list_files.dart

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ class _FileTileState extends State<FileTile> {
123123
}
124124

125125
@override
126-
void initState() {
126+
void initState() async {
127127
super.initState();
128128

129129
_loadThumbnailIfAvailable();
@@ -140,7 +140,19 @@ class _FileTileState extends State<FileTile> {
140140
@override
141141
Widget build(BuildContext context) {
142142
return SimpleCard(
143-
onTap: () {},
143+
onTap: () async {
144+
if (file.metadata?.isDirectory == false) {
145+
final document = await file.metadata!.uri!.toDocumentFile();
146+
147+
print(document!.uri.toString());
148+
149+
final onNewLine = getDocumentContent(file.metadata!.uri!);
150+
151+
onNewLine.listen((newLine) {
152+
print('New line: $newLine');
153+
});
154+
}
155+
},
144156
children: [
145157
Padding(
146158
padding: const EdgeInsets.only(bottom: 12),
@@ -180,6 +192,7 @@ class _FileTileState extends State<FileTile> {
180192
'id': '${file.data?[DocumentFileColumn.id]}',
181193
'parentUri': '${file.metadata?.parentUri}',
182194
'rootUri': '${file.metadata?.rootUri}',
195+
'uri': '${file.metadata?.uri}',
183196
},
184197
),
185198
if (file.metadata?.isDirectory ?? false)

example/lib/main.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:async';
22

33
import 'package:flutter/material.dart';
4+
import 'package:permission_handler/permission_handler.dart';
45
import 'package:shared_storage/shared_storage.dart';
56
import 'package:shared_storage_example/persisted_uri_card.dart';
67

@@ -39,9 +40,13 @@ class _AppState extends State<App> {
3940
}
4041

4142
Future<void> _loadPersistedUriPermissions() async {
42-
persistedPermissionUris = await persistedUriPermissions();
43+
final status = await Permission.storage.request();
4344

44-
setState(() => {});
45+
if (status.isGranted) {
46+
persistedPermissionUris = await persistedUriPermissions();
47+
48+
setState(() => {});
49+
}
4550
}
4651

4752
void _openDocumentTree() async {

lib/src/storage_access_framework/document_file.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ class DocumentFile {
4242

4343
Future<bool?> delete() => saf.delete(uri);
4444

45+
Future<DocumentFile?> copy(Uri destination) => saf.copy(uri, destination);
46+
47+
Stream<String> getContent(Uri destination) => saf.getDocumentContent(uri);
48+
4549
Future<DocumentFile?> createDirectory(String displayName) =>
4650
saf.createDirectory(uri, displayName);
4751

lib/src/storage_access_framework/saf.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,40 @@ Future<DocumentFile?> parentFile(Uri uri) async {
465465

466466
return DocumentFile.fromMap(parent);
467467
}
468+
469+
/// Copy a document `uri` to the `destination`
470+
///
471+
/// This API uses the `createFile` and `readDocumentContent` API's behind the scenes
472+
Future<DocumentFile?> copy(Uri uri, Uri destination) async {
473+
const kCopy = 'copy';
474+
475+
const kUri = 'uri';
476+
const kDestination = 'destination';
477+
478+
final args = <String, String>{kUri: '$uri', kDestination: '$destination'};
479+
480+
final duplicatedFile =
481+
await kDocumentFileChannel.invokeMapMethod<String, dynamic>(kCopy, args);
482+
483+
if (duplicatedFile == null) return null;
484+
485+
return DocumentFile.fromMap(duplicatedFile);
486+
}
487+
488+
/// Get content of a given document `uri`
489+
///
490+
/// Equivalent to `contentDescriptor` usage
491+
///
492+
/// [Refer to details](https://developer.android.com/training/data-storage/shared/documents-files#input_stream)
493+
Stream<String> getDocumentContent(Uri uri) {
494+
const kGetDocumentContent = 'getDocumentContent';
495+
496+
const kUri = 'uri';
497+
const kEvent = 'event';
498+
499+
final args = <String, String>{kUri: '$uri', kEvent: kGetDocumentContent};
500+
501+
final onNewLine = kDocumentFileEventChannel.receiveBroadcastStream(args);
502+
503+
return onNewLine.map<String>((event) => event as String);
504+
}

0 commit comments

Comments
 (0)