2025년 9월 14일 일요일

Unity Android Storage Access Framework(SAF)

 Android Storage Access Framework(SAF)

안드로이드 앱을 만들다 보면 “사용자가 파일을 직접 선택해서 열거나 저장하게 하고 싶다” 는 순간이 꼭 찾아옵니다. 예전에는 단순히 파일 경로를 받아 File API로 접근하면 됐지만, 보안이나 호환성 문제 때문에 이제는 그렇게 단순하지 않습니다. 실제 해보면 권한 없다고 오류가 발생합니다.

막상 문서 찾고 하다보면 생각보다 잘안됩니다.

이때 등장하는 게 Storage Access Framework(SAF) 입니다. 

SAF는 Android 4.4 (KitKat)부터 도입된 파일 접근 방식으로,
앱이 사용자가 직접 선택한 파일이나 폴더에만 접근할 수 있도록 도와주는 프레임워크입니다.

즉, 개발자가 마음대로 기기 저장소를 뒤지지 못하고, 사용자가 시스템이 제공하는 파일 선택 화면에서 골라준 파일에만 접근할 수 있습니다.

Unity에서 SAF를 사용하면 안드로이드 파일 선택창이 뜨게 됩니다. 여기에서 사용자가 골라주는 파일에 대해서 Read/Write가 가능해집니다.

현재 Android 전문으로 설명할것이 아니기 때문에 Unity에서 사용하는 방법만 설명을 드리겠습니다.

일단 GPT를 이용해서 SAF로 만들어 달라면 잘 만들어 줍니다. 그런데 코틀린 소스 파일도 만들어야 하고 폴더 구정에 넣어서 그래들 빌드까지 해야하는데 실제 빈 프로젝트에서는 잘 동작될지 몰라도 기존에 이미 Plugins에 Android를 사용중인 경우 뭔가 잘 되지 않습니다.


Native File Picker for Android & iOS

Asset Store 에서 유용한 Free Asset 을 찾았습니다. 

https://assetstore.unity.com/packages/tools/integration/native-file-picker-for-android-ios-173238

예제는 github 보시면 이해가 될것 입니다.

https://github.com/yasirkula/UnityNativeFilePicker#example-code

github 소스를 보면 이미 빌드가된 aar 형태로 들어있습니다. 따로 안드로이드 빌드는 안해도 되는데 NativeFilePicker.aar의 소스는 공개가 안되어 있습니다.

구조는 간단한데 예제가 거의 없어서 사용하는데 시간이 좀 걸렸습니다.

제가 사용한 예제 코드를 공유합니다. 예제에서는 샘플이므로 동작하지는 않습니다.

💾 저장하기 (Save)

아래 코드는 SaveDataStruct 데이터를 JSON으로 직렬화한 뒤, 임시 저장소(temporaryCachePath)에 기록하고, 이후 SAF 기반으로 사용자가 선택한 경로로 내보냅니다.

// 저장 코드 public void SaveGameData(SaveDataSturct data, string fileName) { // JSON 직렬화 string json = JsonConvert.SerializeObject(data, Formatting.Indented); string combinedData = $"{json}"; try { #if UNITY_ANDROID && !UNITY_EDITOR // 임시 저장 경로 string downloadPath = Application.temporaryCachePath; string fullPath = Path.Combine(downloadPath, fileName); // 파일 저장 File.WriteAllText(fullPath, combinedData); Debug.Log($"Data saved to: {fullPath}"); // SAF Export bool permission = NativeFilePicker.CheckPermission(); if (permission == true) { NativeFilePicker.ExportFile(fullPath, OnFileExported); } else { NativeFilePicker.RequestPermissionAsync((result) => { if (result == NativeFilePicker.Permission.Granted) { NativeFilePicker.ExportFile(fullPath, OnFileExported); } else { Debug.LogError("사용자가 권한을 거부했습니다."); } }); } #endif } catch (Exception ex) { Debug.LogError($"Failed to save data: {ex.Message}"); } } // ExportFile 콜백 private void OnFileExported(bool success) { if (success) { Debug.Log("File exported successfully!"); } else { Debug.Log("File export failed or cancelled."); } }

📂 불러오기 (Load)

아래 코드는 SAF 파일 선택기를 호출해 사용자가 직접 파일을 선택하고, 선택한 파일을 불러옵니다.

// 파일 로드 public void LocalLoad() { #if UNITY_ANDROID && !UNITY_EDITOR if (NativeFilePicker.IsFilePickerBusy()) return; bool permission = NativeFilePicker.CheckPermission(); if (permission == true) { OpenFilePicker(); } else { NativeFilePicker.RequestPermissionAsync((result) => { if (result == NativeFilePicker.Permission.Granted) { OpenFilePicker(); } else { Debug.LogError("사용자가 권한을 거부했습니다."); } }); } #endif } private void OpenFilePicker() { NativeFilePicker.PickFile((path) => { if (path == null) { Debug.Log("Operation cancelled"); } else { Debug.Log("Picked file: " + path); LocalLoadReal(path); } }, new string[] { "text/plain" }); // 허용할 파일 MIME 타입 지정 } private void LocalLoadReal(string fullPath) { if (!File.Exists(fullPath)) { Debug.LogWarning("Save file not found!"); return; } try { // 파일 읽기 샘플 string[] lines = File.ReadAllLines(fullPath); Debug.Log($"Data loaded from: {fullPath}"); // TODO: lines를 역직렬화해서 SaveDataStruct로 변환 } catch (Exception ex) { Debug.LogError($"Failed to load data: {ex.Message}"); return; } }


댓글 없음:

댓글 쓰기