Microsoft MVP성태의 닷넷 이야기
Phone: 16. C# MAUI - /Download 등의 공용 디렉터리에 접근하는 방법 [링크 복사], [링크+제목 복사],
조회: 9462
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일
 

(시리즈 글이 4개 있습니다.)
Phone: 16. C# MAUI - /Download 등의 공용 디렉터리에 접근하는 방법
; https://www.sysnet.pe.kr/2/0/13631

Phone: 17. C# MAUI - Android 내에 Web 서비스 호스팅
; https://www.sysnet.pe.kr/2/0/13632

Phone: 18. C# MAUI - 안드로이드 플랫폼에서의 Activity 제어
; https://www.sysnet.pe.kr/2/0/13634

Phone: 21. C# MAUI - Android 환경에서의 파일 다운로드(DownloadManager)
; https://www.sysnet.pe.kr/2/0/13640




C# MAUI - /Download 등의 공용 디렉터리에 접근하는 방법

가령, "Android Emulator"에 윈도우의 탐색기로부터 "file_example_MP4_1920_18MG.mp4" 파일을 드래그&드롭으로 놓으면 "/Download" 디렉터리에 파일이 생성된 것을 볼 수 있습니다.

자, 그럼 그 파일을 MAUI에서 접근하고 싶다면 어떻게 해야 할까요? 개인적으로 안드로이드 환경의 개발 이력을 모르니 ^^ 이런 간단한 것 하나도 헤매게 되는군요. ^^ 일단, 안드로이드의 파일 탐색기로 본 "/Download" 디렉터리는 사용자가 본 경로일 뿐, 내부적으로 코드에서 접근할 수 있는 경로는 "/storage/emulated/0/Download"입니다. 그럼, 아래와 같이 Download 디렉터리를 열거할 수 있을까요?

string downloadPath = Path.Combine(Android.OS.Environment.ExternalStorageDirectory?.AbsolutePath ?? "",
                        Android.OS.Environment.DirectoryDownloads ?? "");
// downloadPath == "/storage/emulated/0/Download"

// **System.UnauthorizedAccessException:** 'Access to the path '/storage/emulated/0/Download' is denied.'
var files = Directory.GetFiles(downloadPath, "*");
foreach (var file in files)
{
    // ...
}

보는 바와 같이, "System.UnauthorizedAccessException" 예외가 발생합니다. 자, 그렇다면 권한을 주면 된다는 건데요, 이 과정은 2단계로 이뤄집니다. 첫 번째로, Android App이 그와 관련된 권한이 필요하다는 것을 AndroidManifest.xml에 READ_EXTERNAL_STORAGE 권한을 명시해야 합니다.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:allowBackup="true" android:icon="@mipmap/appicon" android:supportsRtl="true"></application>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    
    <uses-sdk />
</manifest>

혹은, C#/MAUI답게 assembly로 권한을 주는 것도 가능합니다.

[assembly: UsesPermission(Name = Android.Manifest.Permission.ReadExternalStorage)]

두 번째로, 사용자로부터 반드시 해당 위치를 접근해도 좋다는 허락을 받아야만 합니다. 이것은 코드로 구현해야 하는데요, 보통은 권한이 필요한 시점에 다음과 같은 코드를 통해 권한 요청을 할 수 있습니다.

// ReadExternalStorage 권한을 사용자가 부여했는지 확인
PermissionStatus status = await Permissions.CheckStatusAsync<Permissions.StorageRead>();
if (status != PermissionStatus.Granted)
{
    // 현재 App에 권한을 준 적이 없다면, 사용자에게 허락을 요청 
    // (일단 한번 허락을 받으면 이후에는 CheckStatusAsync에서 Granted가 뜹니다.) 
    var requestStatus = await Permissions.RequestAsync<Permissions.StorageRead>();
    if (requestStatus != PermissionStatus.Granted)
    {
        return; // 사용자가 허락을 안 했으므로, 더 할 수 있는 작업이 없을 테고!
    }
}

// 이후 코드에서는 ReadExternalStorage 권한이 있으므로 잘 동작함.
// ...[생략]...

저렇게 2가지 조건을 충족했으면, Directory.GetFiles로 접근할 수 있습니다.

string[] files = Directory.GetFiles("/storage/emulated/0/Download");
foreach (var item in files)
{
    if (item.EndsWith(".txt"))
    {
        string text = File.ReadAllText(item);
    }
}

또는, Android/Java로 만들던 코드와 유사하게 Java.IO.File로도 접근할 수 있습니다.

var downloadDir = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryDownloads);
Java.IO.File[] files = downloadDir.ListFiles();

게다가 이게 참 방법이 다양한데요, 검색해 보니까, ContentResolver를 통해서도 조회하는 방법이 나옵니다.

[안드로이드] 안드로이드 Q(API 29)이상에서 이미지 파일 다루기
; https://velog.io/@bonimddal2/안드로이드-안드로이드-QAPI-29이상에서-이미지-파일-다루기

콘텐츠 제공자 기본사항
; https://developer.android.com/guide/topics/providers/content-provider-basics?hl=ko

Using Xamarin Android with MediaStore – Part 2
; https://keithbeattyblog.wordpress.com/2021/11/14/using-xamarin-android-with-mediastore-part-2/

위의 글을 종합해 보면 MAUI에서는 이렇게 구현할 수 있습니다.

{
    Android.Content.ContentResolver contentResolver = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity.ContentResolver;

    string [] projection = [
        Android.Provider.MediaStore.Downloads.InterfaceConsts.Id,
        Android.Provider.MediaStore.Downloads.InterfaceConsts.DisplayName,
        Android.Provider.MediaStore.Downloads.InterfaceConsts.MimeType,
    ];

    // mime type이 video/mp4 유형의 파일만 조회
    string selection = $"{Android.Provider.MediaStore.Downloads.InterfaceConsts.MimeType} = ?";
    string[] selectionArgs = [ "video/mp4" ];

    var cursor = contentResolver.Query(Android.Provider.MediaStore.Downloads.ExternalContentUri, projection, selection, selectionArgs, null);

    int count = cursor.Count;
}

이 외에, 마이크로소프트에서 MAUI의 다중 플랫폼 지원에 맞게 감싼 File picker도 있습니다.

File picker
; https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/storage/file-picker?tabs=android

단지, 이건 파일을 선택하는 외부 선택 창을 띄운 후 거기서 사용자가 직접 선택한 파일 목록을 반환해 주는 역할을 합니다. Windows API라면 FileDialog 창을 띄우는 것과 유사한 사용자 경험인데요, 이 방법이 다른 것들과 비교해 좋은 이유라면 별도의 권한 설정이 필요 없다는 점입니다. 왜냐하면, 1) 내부에서 직접 코드로 접근하는 것이 아닌, 외부의 서비스에 요청해 결과를 받는 방식이라는 점과, 2) 사용자 스스로 파일을 선택했다는 것으로 인해 별도의 권한 체크는 필요 없게 만든 것 같습니다.

그래서 코드는 대충 다음과 같이 작성할 수 있는데요,

{
    FilePickerFileType customFileType = new FilePickerFileType(
        new Dictionary<DevicePlatform, IEnumerable<string>>
        {
                        { DevicePlatform.iOS, new[] { "public.audio" } }, // UTType values
                        { DevicePlatform.Android, new[] { "audio/*" } }, // MIME type
                        { DevicePlatform.WinUI, new[] { ".mp3", ".wav", ".wma", ".m4a", ".flac" } }, // file extension
                        { DevicePlatform.Tizen, new[] { "*/*" } },
                        { DevicePlatform.macOS, new[] { ".mp3", ".wav", ".wma", ".m4a", ".flac" } }, // UTType values
        });
    PickOptions options = new()
    {
        PickerTitle = "Please select audio file's",
        FileTypes = customFileType
    };

    // Download 디렉터리뿐만 아니라, 기기의 다른 공용 디렉터리에서 파일 선택이 가능
    var fileResult = await FilePicker.Default.PickMultipleAsync(options);

    if (fileResult != null)
    {
        foreach (FileResult file in fileResult)
        {
            string filePath = file.FullPath;
            // filePath == "/data/user/0/com.companyname.simpleplayer/cache/2203693cc04e0be7f4f024d5f9499e13/3ebb31723ffa4b16b1eeeb44f4ae8a25/test.mp3"

            FileStream fs = File.OpenRead(filePath);
            long fileLen = fs.Length;
        }

    }
}

위의 코드는 사용자가 파일을 선택하는 창을 띄운 후, 그 목록을 fileResult로 받아 처리할 수 있게 해줍니다. 게다가 재미있는 것은 File picker를 통해 사용자가 선택한 파일은 분명히 /Download의 경로였지만 코드에서 반환받은 파일의 경로는 "/data/user/0/com.companyname.simpleplayer/cache/..."라는 점입니다. 실제로 adb shell로 들어가 확인해 보면,

C:\Program Files (x86)\Android\android-sdk> adb shell
1|emu64xa:/data $ run-as com.companyname.simpleplayer
emu64xa:/data/user/0/com.companyname.simpleplayer $ ls
cache  code_cache  files
emu64xa:/data/user/0/com.companyname.simpleplayer $ cd cache/2203693cc04e0be7f4f024d5f9499e13/3ebb31723ffa4b16b1eeeb44f4ae8a25
emu64xa:/data/... $ ls -l
total 8684
-rw------- 1 u0_a165 u0_a165_cache 8887261 2024-05-19 23:51 test.mp3

link 등의 경로가 아닌 걸로 봐서 실제 파일이 App 전용 경로의 cache 디렉터리로 복사된 듯합니다.




혹시 그렇다면, 우리도 (Android Emulator의 "Files"처럼) Android 탐색기와 같은 프로그램을 만들고 싶다면 어떻게 해야 할까요? 이런 경우라면 단순히 READ_EXTERNAL_STORAGE 권한으로는 안 될 텐데요, 검색해 보면 이런 답변들이 나옵니다.

MAUI on Android : listing folder contents of an SD Card and writing in it
; https://stackoverflow.com/questions/75880663/maui-on-android-listing-folder-contents-of-an-sd-card-and-writing-in-it

MAUI :System.UnauthorizedAccessException: Access to the path is denied
; https://stackoverflow.com/questions/73221292/maui-system-unauthorizedaccessexception-access-to-the-path-is-denied

정리하면, 이것 역시 사용자로부터 명식적으로 권한 허락을 받아야 한다는 점입니다. 이를 위해 이전에 READ_EXTERNAL_STORAGE 권한을 요청했을 때처럼 AndroidManifest.xml 파일에 MANAGE_EXTERNAL_STORAGE 항목을 하나 추가하고,

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />

코드에서는 사용자에게 요청 창을 띄워 권한을 획득해야 합니다.

private void CheckPermission()
{
    if (!Android.OS.Environment.IsExternalStorageManager)
    {
        Intent intent = new Intent();
        intent.SetAction(Android.Provider.Settings.ActionManageAppAllFilesAccessPermission);
        Android.Net.Uri? uri = Android.Net.Uri.FromParts("package", this.PackageName, null);
        intent.SetData(uri);
        StartActivity(intent);
    }
}

이렇게 하고 실행하면, 사용자에게 "Allow access to manage all files" 여부를 묻는 창을 띄웁니다. (이러한 요구는 사용자가 허락한 이후에는 두 번 다시 뜨지 않습니다.) 사용자가 허락을 하고, "뒤로 가기" 버튼을 눌러 응용 프로그램으로 돌아가면 이후 사용자 기기의 파일 시스템을 (역시 시스템이 허락하는 수준 내에서) 정상적으로 접근할 수 있게 됩니다.




당연히, (전역이 아닌) App 단위로 접근할 수 있는 경로라면 별다른 권한이 필요 없습니다. 예를 들어 아래와 같은 코드인데요,

{
    string myPath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal);
    // myPath == "/data/user/0/com.companyname.simpleplayer/files/Documents/"

    FileStream fs = new(Path.Combine(myPath, "test.json"), FileMode.Create); // 예외 발생
    StreamWriter sw = new StreamWriter(fs, System.Text.Encoding.UTF8);
    await sw.WriteAsync("TEST is good");
    sw.Close();
    fs.Close();
}

하지만 FileMode.Create로 파일을 생성하는 단계에서 DirectoryNotFoundException 예외가 발생합니다.

**System.IO.DirectoryNotFoundException:** 'Could not find a part of the path '/data/user/0/com.companyname.simpleplayer/files/Documents/test.json'.'

보니까, /Documents 디렉터리가 없어서 그런 것인데요, 일단 저 디렉터리를 만들어 주면 접근이 됩니다.

string myPath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal);

if (Directory.Exists(myPath) == false)
{
    Directory.CreateDirectory(myPath);
}

(따라서 ^^ Environment.GetFolderPath를 이용해 반환받은 디렉터리 경로를 사용할 때는 약간의 주의를 요구합니다.)




[appid가 "com.companyname.mauiapp1"인 경우, Android 관련 경로를 구하는 각종 MAUI 코드]

Microsoft.Maui.ApplicationModel.Platform.CurrentActivity.ApplicationContext.ApplicationInfo.DataDir
예) /data/user/0/com.companyname.mauiapp1

FileSystem.Current.AppDataDirectory
예) /data/user/0/com.companyname.mauiapp1/files

FileSystem.Current.CacheDirectory
Microsoft.Maui.ApplicationModel.Platform.CurrentActivity.ApplicationContext.CacheDir
예) /data/user/0/com.companyname.mauiapp1/cache

Android.OS.Environment.ExternalStorageDirectory // External storage: https://learn.microsoft.com/en-us/previous-versions/xamarin/android/platform/files/external-storage#private-external-files
예) /storage/emulated/0

Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryDownloads);
예) /storage/emulated/0/Download

Microsoft.Maui.ApplicationModel.Platform.CurrentActivity.ApplicationContext.GetExternalFilesDir("")
예) /storage/emulated/0/Android/data/com.companyname.mauiapp1/files

Microsoft.Maui.ApplicationModel.Platform.CurrentActivity.ApplicationContext.ExternalCacheDir
예) /storage/emulated/0/Android/data/com.companyname.mauiapp1/cache

files = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity.ApplicationContext.GetExternalCacheDirs();
예) files[0] == /storage/emulated/0/Android/data/com.companyname.mauiapp1/cache
예) files[1] == /storage/0AEC-3C13/Android/data/com.companyname.mauiapp1/cache

Microsoft.Maui.ApplicationModel.Platform.CurrentActivity.ApplicationContext.ApplicationInfo.DeviceProtectedDataDir
예) /data/user_de/0/com.companyname.mauiapp1

Microsoft.Maui.ApplicationModel.Platform.CurrentActivity.ApplicationContext.ApplicationInfo.PublicSourceDir
예) /data/app/~~W76ix361_DLAG9dJ2gTUeQ==/com.companyname.mauiapp1-Fnpp2MLV-yQe-lRxjZy5mA==/base.apk

Android Studio의 Device Explorer에서 파일을 다운로드하는 경우, 대상 디렉터리를 바꾸지 않는다면 윈도우의 경우 아래의 경로에 저장합니다.

형식) %LOCALAPPDATA%\Google\{안드로드스튜디오버전}\device-explorer\{에뮬레이터 이름}\_\storage\emulated\0\Android\data\{AppID}\files\Download

예제) %LOCALAPPDATA%\Google\AndroidStudio2023.3\device-explorer\Pixel 5 - API 33\_\storage\emulated\0\Android\data\com.companyname.simpleplayer\files\Download




참고로, CA1416 경고가 발생한다면?

warning CA1416: This call site is reachable on: 'Android' 21.0 and later. 'Environment.IsExternalStorageManager' is only supported on: 'android' 30.0 and later. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)


말 그대로 IsExternalStorageManager 속성은 30.0부터 지원하는 기능인데 현재 MAUI 프로젝트가 21.0 대상으로 빌드하고 있기 때문에 발생하는 것입니다.

따라서 저 기능을 쓰지 말든가, 아니면 MAUI 프로젝트의 최소 Android 지원 버전을 30.0으로 바꾸면 되는데요, 후자의 경우 csproj에서 SupportedOSPlatformVersion 변경으로 가능합니다.

<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">30.0</SupportedOSPlatformVersion>




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 11/21/2024]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2024-11-21 09시10분
Maui.Android에서 폴더 관련 내용 정리 잘 되어 있네요.
지현명

... 106  107  108  109  110  111  112  113  114  115  116  117  118  [119]  120  ...
NoWriterDateCnt.TitleFile(s)
10949정성태4/28/201619876.NET Framework: 575. SharedDomain과 JIT 컴파일파일 다운로드1
10948정성태4/28/201623820.NET Framework: 574. .NET - 눈으로 확인하는 SharedDomain의 동작 방식 [3]파일 다운로드1
10947정성태4/27/201621682.NET Framework: 573. .NET CLR4 보안 모델 - 4. CLR4 보안 모델에서의 조건부 APTCA 역할파일 다운로드1
10946정성태4/26/201624510VS.NET IDE: 106. Visual Studio 2015 확장 - INI 파일을 위한 사용자 정의 포맷 기능 (Syntax Highlighting)파일 다운로드1
10945정성태4/26/201618268오류 유형: 327. VSIX 프로젝트 빌드 시 The "VsTemplatePaths" task could not be loaded from the assembly 오류 발생
10944정성태4/22/201619514디버깅 기술: 80. windbg - 풀 덤프 파일로부터 텍스트 파일의 내용을 찾는 방법
10943정성태4/22/201624368디버깅 기술: 79. windbg - 풀 덤프 파일로부터 .NET DLL을 추출/저장하는 방법 [1]
10942정성태4/19/201619671디버깅 기술: 78. windbg 사례 - .NET 예외가 발생한 시점의 오류 분석 [1]
10941정성태4/19/201619579오류 유형: 326. Error MSB8020 - The build tools for v120_xp (Platform Toolset = 'v120_xp') cannot be found.
10940정성태4/18/201622844Windows: 116. 프로세스 풀 덤프 시간을 줄여 주는 Process Reflection [3]
10939정성태4/18/201623878.NET Framework: 572. .NET APM 비동기 호출의 Begin...과 End... 조합 [3]파일 다운로드1
10938정성태4/13/201623443오류 유형: 325. 파일 삭제 시 오류 - Error 0x80070091: The directory is not empty.
10937정성태4/13/201631666Windows: 115. UEFI 모드로 윈도우 10 설치 가능한 USB 디스크 만드는 방법
10936정성태4/8/201642352Windows: 114. 삼성 센스 크로노스 7 노트북의 운영체제를 USB 디스크로 새로 설치하는 방법 [3]
10935정성태4/7/201626651웹: 32. Edge에서 Google Docs 문서 편집 시 한영 전환키가 동작 안하는 문제
10934정성태4/5/201625379디버깅 기술: 77. windbg의 콜스택 함수 인자를 쉽게 확인하는 방법 [1]
10933정성태4/5/201630987.NET Framework: 571. C# - 스레드 선호도(Thread Affinity) 지정하는 방법 [8]파일 다운로드1
10932정성태4/4/201623282VC++: 96. C/C++ 식 평가 - printf("%d %d %d\n", a, a++, a);
10931정성태3/31/201623557개발 환경 구성: 283. Hyper-V 내에 구성한 Active Directory 환경의 시간 구성 방법 [3]
10930정성태3/30/201621510.NET Framework: 570. .NET 4.5부터 추가된 CLR Profiler의 실행 시 Rejit 기능
10929정성태3/29/201631619.NET Framework: 569. ServicePointManager.DefaultConnectionLimit의 역할파일 다운로드1
10928정성태3/28/201637335.NET Framework: 568. ODP.NET의 완전한 닷넷 버전 Oracle ODP.NET, Managed Driver [2]파일 다운로드1
10927정성태3/25/201626543.NET Framework: 567. System.Net.ServicePointManager의 DefaultConnectionLimit 속성 설명
10926정성태3/24/201626083.NET Framework: 566. openssl의 PKCS#1 PEM 개인키 파일을 .NET RSACryptoServiceProvider에서 사용하는 방법 [10]파일 다운로드1
10925정성태3/24/201620386.NET Framework: 565. C# - Rabin-Miller 소수 생성 방법을 이용하여 RSACryptoServiceProvider의 개인키를 직접 채워보자 - 두 번째 이야기파일 다운로드1
10924정성태3/22/201621037오류 유형: 324. Visual Studio에서 Azure 클라우드 서비스 생성 시 Failed to initialize the PowerShell host 에러 발생
... 106  107  108  109  110  111  112  113  114  115  116  117  118  [119]  120  ...