dart-lang / sdk

The Dart SDK, including the VM, dart2js, core libraries, and more.
https://dart.dev
BSD 3-Clause "New" or "Revised" License
10k stars 1.54k forks source link

Request for Support of `content://` URI in Flutter's File Class for Android #54878

Closed devfemibadmus closed 5 months ago

devfemibadmus commented 5 months ago

Use case

Request support for android content:// uri in the File class there is similar closed #25659. The conclusion of the #25659 is to use a plugin https://pub.dev/packages/flutter_absolute_path which i dont think that will be Good bcus this plugin copy the files temporary to another location then returns its absolute dir imagine working on large project where u work with many files, user will suffer for storage or sometimes not even enough storage to copy single file

and here #bug-flutter-fileimagevideo-not-working-with-android-action_open_document_tree-but-works-fine-in-kotlin

I convert the content:// uri to get string absolute path but will end up in permission denied even thou permission been given successfully meaning converting doesn't work we can only access through content:// uri bcus we got that from https://developer.android.com/training/data-storage/shared/documents-files

this issue is all derived from this simple flutter project https://github.com/devfemibadmus/whatsapp-status-saver

which we use https://developer.android.com/training/data-storage/shared/documents-files to get permission to folder, the code in android works fine bcus we can access content:// uri in adnroid but wont in flutter bcus we can't access content:// uri in File and with this, this simple flutter project is limited.

Using manage_external_storage permission works fine but security issue app reject from playstore by the way not really recommended bcus of security please flutter request for feature Android support for content:// uri in the File class

Proposal

Another exception was thrown: PathNotFoundException: Cannot retrieve length of file, path =
'content://com.android.externalstorage.documents/tree/primary%3AAndroid%2Fmedia%2Fcom.whatsapp%2FWhatsApp%2FMedia%2F.Statuses/document/primary%3AAndroid%2Fmedia%2Fcom.w
hatsapp%2FWhatsApp%2FMedia%2F.Statuses%2F21ffcc43b1e141efaef73cd5a099ef0f.jpg' (OS Error: No such file or directory, errno = 2)

Sample code

https://github.com/devfemibadmus/folderpermission

kotlin MainActivity.kt

package com.blackstackhub.folderpicker

import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.util.Log
import androidx.annotation.NonNull
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File

import android.content.Intent
import androidx.documentfile.provider.DocumentFile

class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.blackstackhub.folderpicker"
    private val PERMISSIONS = arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE, android.Manifest.permission.READ_EXTERNAL_STORAGE)
    private val TAG = "MainActivity"
    private val PICK_DIRECTORY_REQUEST_CODE = 123
    private var STATUS_DIRECTORY: DocumentFile? = null
    private val BASE_DIRECTORY: Uri = Uri.fromFile(File("/storage/emulated/0/Android/media/com.whatsapp/WhatsApp/Media/.Statuses/"))

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
            when (call.method) {
                "isPermissionGranted" -> {
                    result.success(isPermissionGranted())
                }
                "requestSpecificFolderAccess" -> {
                    result.success(requestSpecificFolderAccess())
                }
                "fetchFilesFromDirectory" -> {
                    result.success(fetchFilesFromDirectory())
                }
                else -> {
                    result.notImplemented()
                }
            }
        }
    }

    private fun isPermissionGranted(): Boolean {
        Log.d(TAG, "isPermissionGranted: $STATUS_DIRECTORY")
        return STATUS_DIRECTORY != null && STATUS_DIRECTORY!!.canWrite() && STATUS_DIRECTORY!!.canRead()
    }

    private fun requestSpecificFolderAccess(): Boolean {
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
        intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, BASE_DIRECTORY)
        startActivityForResult(intent, PICK_DIRECTORY_REQUEST_CODE)
        return true
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
        super.onActivityResult(requestCode, resultCode, resultData)
        if (requestCode == PICK_DIRECTORY_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
            val treeUri: Uri? = resultData?.data
            treeUri?.let {
                contentResolver.takePersistableUriPermission(
                    it,
                    Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
                )
                STATUS_DIRECTORY = DocumentFile.fromTreeUri(this, it)
            }
        }
    }

    private fun fetchFilesFromDirectory(): List<String> {
        val statusFileNames = mutableListOf<String>()
        Log.d(TAG, "STATUS_DIRECTORY: $STATUS_DIRECTORY")
        STATUS_DIRECTORY?.let { rootDirectory ->
            rootDirectory.listFiles()?.forEach { file ->
                if (file.isFile && file.canRead()) {
                    statusFileNames.add(file.uri.toString())
                }
            }
        }

        return statusFileNames
    }
}

Flutter main.dart

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Status Downloader',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  bool _isPermissionGranted = false;
  List<String> _files = [];

  @override
  void initState() {
    super.initState();
    _checkPermission();
  }

  Future<void> _checkPermission() async {
    bool isGranted = await FolderPicker.isPermissionGranted();
    setState(() {
      _isPermissionGranted = isGranted;
    });
    if (_isPermissionGranted) {
      _fetchFiles();
    }
  }

  Future<void> _requestPermission() async {
    await FolderPicker.requestPermission();
    _checkPermission();
  }

  Future<void> _fetchFiles() async {
    List<String> files = await FolderPicker.fetchFilesFromDirectory();
    setState(() {
      _files = files;
    });
  }
/*
  String convertContentUriToFilePath(String contentUri) {
    String prefix = "primary:";
    String newPathPrefix = "/storage/emulated/0/";

    String newPath = contentUri.replaceAll("%2F", "/");
    newPath = newPath.replaceAll("%3A", ":");
    newPath = newPath.replaceAll("%2E", ".");
    //newPath = newPath.replaceAll(prefix, "");
    newPath = newPath.substring(newPath.indexOf('document/') + 9);
    //newPath = newPath.substring(newPath.indexOf(':') + 1);
    newPath = newPath.replaceAll(prefix, newPathPrefix);
    return newPath;
  }

*/

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Status Downloader'),
      ),
      body: Center(
        child: _isPermissionGranted
            ? Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Text('Permission Granted'),
                  const SizedBox(height: 20),
                  ElevatedButton(
                    onPressed: _fetchFiles,
                    child: const Text('Fetch Files'),
                  ),
                  const SizedBox(height: 20),
                  Expanded(
                    child: ListView.builder(
                      itemCount: _files.length,
                      itemBuilder: (context, index) {
                        return _files[index].endsWith(".jpg")
                            ? Image.file(File(_files[
                                index])) //try convertContentUriToFilePath(_files[index])
                            : ListTile(
                                title: Text(_files[index]),
                              );
                      },
                    ),
                  ),
                ],
              )
            : ElevatedButton(
                onPressed: _requestPermission,
                child: const Text('Request Permission'),
              ),
      ),
    );
  }
}

class FolderPicker {
  static const MethodChannel _channel =
      MethodChannel('com.blackstackhub.folderpicker');

  static Future<bool> isPermissionGranted() async {
    try {
      final bool result = await _channel.invokeMethod('isPermissionGranted');
      return result;
    } on PlatformException catch (e) {
      print("Failed to check permission: '${e.message}'.");
      return false;
    }
  }

  static Future<void> requestPermission() async {
    try {
      await _channel.invokeMethod('requestSpecificFolderAccess');
    } on PlatformException catch (e) {
      print("Failed to request permission: '${e.message}'.");
    }
  }

  static Future<List<String>> fetchFilesFromDirectory() async {
    try {
      final List<dynamic> result =
          await _channel.invokeMethod('fetchFilesFromDirectory');
      print(result);
      print(result.length);
      return result.cast<String>();
    } on PlatformException catch (e) {
      print("Failed to fetch files: '${e.message}'.");
      return [];
    }
  }
}
devfemibadmus commented 5 months ago

@stuartmorgan

stuartmorgan commented 5 months ago

As I said in the referenced issue, this is likely a wontfix, since:

it's not at all clear how many of the APIs in File would work. content URIs aren't files, they are their own distinct concept; that's why they have a different scheme in the first place.

As to this:

The conclusion of the #25659 is to use a plugin https://pub.dev/packages/flutter_absolute_path which i dont think that will be Good bcus this plugin copy the files temporary to another location

That was not "the conclusion" it was a suggestion from another user for one possible solution. There are better options that don't involve File support, such as a plugin that specifically provide an interface for the kinds of things that can be done with content:// URIs, such as reading their data, without copying them, but also without suggesting that they can be, e.g., moved to a different path, which they can't.

For instance, there's been some discussion of reworking cross_file to allow for arbitrary implementations, including content:// support.

devfemibadmus commented 5 months ago

Correct

such as a plugin that specifically provide an interface for the kinds of things that can be done with content:// URIs, such as reading their data, without copying them, but also without suggesting that they can be, e.g., moved to a different path, which they can't.

Good

there's been some discussion of reworking cross_file to allow for arbitrary implementations, including content:// support.`

inconclusion i should await the reworking ?

devfemibadmus commented 5 months ago

just that there are no better if these feature is not available

only ways are to render bytes, file copy, folder moved

brianquinlan commented 5 months ago

My initial thought is that this doesn't belong in File unless the API for accessing these URIs is identical to how files are accessed i.e. through open, read, etc. POSIX system calls.

From what @stuartmorgan wrote, that seems to not be the case - it uses the ContentProvider API, right?

devfemibadmus commented 5 months ago

@brianquinlan nah doesn't use ContentProviderAPI its just like a normal path in android but instead of usual str /path/to/file its Uri instead

Uri uri = Uri.parse("file:///path/to/your/file");
File file = new File(uri.getPath());
stuartmorgan commented 5 months ago

You've shown a file: URI, not a content: URI. As I noted above, those have very different semantics.

brianquinlan commented 5 months ago

@devfemibadmus In your example, does converting the content: URI to a file path result in Image.file working?

devfemibadmus commented 5 months ago

You've shown a file: URI, not a content: URI. As I noted above, those have very different semantics.

Thats just an example

@devfemibadmus In your example, does converting the content: URI to a file path result in Image.file working?

nah, Oh yeah It's ContentProviderAPI

My initial thought is that this doesn't belong in File unless the API for accessing these URIs is identical to how files are accessed i.e. through open, read, etc. POSIX system calls.

From what @stuartmorgan wrote, that seems to not be the case - it uses the ContentProvider API, right?

You're right it uses ContentProvider API, but still since its lead to a file destination, should be called through File?

stuartmorgan commented 5 months ago

but still since its lead to a file destination

It doesn't though, it resolved via an interprocess system-meditated data transfer.

devfemibadmus commented 5 months ago

but still since its lead to a file destination

It doesn't though, it resolved via an interprocess system-meditated data transfer.

so what do we use? File?

devfemibadmus commented 5 months ago

@stuartmorgan @lrhn @brianquinlan

lrhn commented 5 months ago

We should not use theFile class for any URI scheme other than file:.

The File class represents a POSIX file, or its path really, which is why it can be converted to and from a File: URI.

This is something else. It should be represented by something else.

If the problem is that some existing code accepts only File objects, for a use whether a "content" object could also be accepted, then we may need to introduce a supertype for "readable resources". (I'd consider introducing a new abstraction instead of using the platform File class, because this sounds like something slightly different. A ReadableResource with FileResource and ContentResource subtypes, perhaps.)

brianquinlan commented 5 months ago

so what do we use? File?

I think that using File is the wrong solution. This seems like an Android-specific problem that, as @stuartmorgan said, can be solved with a Flutter plugin.

I'm closing this issue because I think that it is out-of-scope for Dart. If you disagree, please reopen with your reasoning.

devfemibadmus commented 5 months ago

can be solved with a Flutter plugin.

and using FIle is the way we can access that which is from Dart, we have file

In Dart, there is a built-in class called File which represents a file on the filesystem.

final file = File(path);
// here path should be /Document/path/to/file/

nah, Oh yeah It's ContentProviderAPI

My initial thought is that this doesn't belong in File unless the API for accessing these URIs is identical to how files are accessed i.e. through open, read, etc. POSIX system calls. From what @stuartmorgan wrote, that seems to not be the case - it uses the ContentProvider API, right?

You're right it uses ContentProvider API, but still since its lead to a file destination, should be called through File?

So if you say this should be solved with flutter plugin, i will like you to expatiate maybe i can do that

devfemibadmus commented 5 months ago

We should not use theFile class for any URI scheme other than file:.

The File class represents a POSIX file, or its path really, which is why it can be converted to and from a File: URI.

This is something else. It should be represented by something else.

If the problem is that some existing code accepts only File objects, for a use whether a "content" object could also be accepted, then we may need to introduce a supertype for "readable resources". (I'd consider introducing a new abstraction instead of using the platform File class, because this sounds like something slightly different. A ReadableResource with FileResource and ContentResource subtypes, perhaps.)

YES! YES!! PLEASE!!!

sachinkhatripro commented 3 months ago

Content Uri are now the standard way of accessing files in android. So, its frustrating that there is no direct support in Flutter/Dart for it. A third party plugin is not the best solution for this as it may or may not be maintained by the developer and may be buggy.

devfemibadmus commented 3 months ago

Content Uri are now the standard way of accessing files in android. So, its frustrating that there is no direct support in Flutter/Dart for it. A third party plugin is not the best solution for this as it may or may not be maintained by the developer and may be buggy.

finally someone is here for me

sachinkhatripro commented 3 months ago

Content Uri are now the standard way of accessing files in android. So, its frustrating that there is no direct support in Flutter/Dart for it. A third party plugin is not the best solution for this as it may or may not be maintained by the developer and may be buggy.

finally someone is here for me

I believe you can reopen the request. This requirement is a must have feature.

devfemibadmus commented 3 months ago

I believe you can re-open the request. This requirement is a must have feature.

you cannot re-open your own issues if a repo collaborator closed them @brianquinlan @stuartmorgan @lrhn

lrhn commented 3 months ago

Does sound like you want a ContentReader API that accepts URIs and can read content through the corresponding Android API.

That's not the File class. It may not be a class that can even exist outside of Android.

Maybe the behavior is close enough to File that it can pretend to implement File. More likely, because the path getter won't even work, it will be a separate API which can maybe work with file URIs on all native platforms, and also with content URIs on Android.

But it's not the File class. Not sure what it is, if not a complete implementation of ContentResolver.

devfemibadmus commented 3 months ago

Damn.............this gonna be another limitation for flutter even thou its android?

sachinkhatripro commented 3 months ago

I have opened a feature request on Flutter repository. https://github.com/flutter/flutter/issues/147037#issue-2252357646

devfemibadmus commented 1 month ago

update, we getting the features

https://github.com/flutter/website/pull/10472

https://github.com/flutter/packages/pull/6625

https://docs.google.com/document/d/12pJDtl0yubyc68UqKo2hQ7XJwv86_ixCjNemQ7ENCBQ/edit#heading=h.ebkwt1wlot63

theskyblockman commented 1 month ago

we getting the features

@devfemibadmus

I am not sure we are getting them that soon, cross_file would first need to be federated until some new features are merged, when this will be done (anybody can do this! If you feel you are capable, try to make it work and to get it merged), I'll probably do another proposal/PR once cross_file will be federated. I did https://github.com/flutter/packages/pull/6625 mainly to get things moving with some code and ideas we can iterate on.