# 네이티브 앱 이미지 저장 기능 구현 가이드 ## 개요 웹뷰에서 생성한 이미지(현금영수증 등)를 iOS/Android 네이티브 환경에서 갤러리에 저장하는 기능 구현 가이드입니다. ## 웹 → 네이티브 통신 스펙 ### 메시지 타입 ``` type: 'saveImage' ``` ### 요청 데이터 구조 ```typescript { type: 'saveImage', data: { imageData: string, // base64 인코딩된 이미지 데이터 (순수 base64, data URL 헤더 제외) fileName?: string, // 파일명 (optional, 기본값: timestamp) mimeType?: string // MIME 타입 (optional, 기본값: 'image/png') }, callbackId: string // 콜백 식별자 } ``` ### 응답 데이터 구조 ```typescript { success: boolean, // 성공 여부 data?: { filePath?: string // 저장된 파일 경로 (optional) }, error?: string, // 에러 메시지 (실패 시) callbackId: string // 요청 시 받은 callbackId } ``` --- ## iOS (Swift) 구현 예시 ### 1. WKWebView 메시지 핸들러 등록 ```swift import WebKit import Photos class WebViewController: UIViewController, WKScriptMessageHandler { var webView: WKWebView! override func viewDidLoad() { super.viewDidLoad() let configuration = WKWebViewConfiguration() configuration.userContentController.add(self, name: "bridge") webView = WKWebView(frame: .zero, configuration: configuration) view.addSubview(webView) } func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { guard let dict = message.body as? [String: Any], let type = dict["type"] as? String, let callbackId = dict["callbackId"] as? String else { return } switch type { case "saveImage": handleSaveImage(data: dict["data"] as? [String: Any], callbackId: callbackId) default: break } } } ``` ### 2. 이미지 저장 로직 구현 ```swift extension WebViewController { func handleSaveImage(data: [String: Any]?, callbackId: String) { guard let data = data, let imageDataString = data["imageData"] as? String, let imageData = Data(base64Encoded: imageDataString), let image = UIImage(data: imageData) else { sendResponse( success: false, error: "Invalid image data", callbackId: callbackId ) return } let fileName = data["fileName"] as? String ?? "image_\(Date().timeIntervalSince1970).png" // 사진 라이브러리 권한 확인 checkPhotoLibraryPermission { [weak self] granted in guard granted else { self?.sendResponse( success: false, error: "Photo library permission denied", callbackId: callbackId ) return } // 사진 저장 PHPhotoLibrary.shared().performChanges({ PHAssetCreationRequest.creationRequestForAsset(from: image) }) { success, error in DispatchQueue.main.async { if success { self?.sendResponse( success: true, data: ["filePath": fileName], callbackId: callbackId ) } else { self?.sendResponse( success: false, error: error?.localizedDescription ?? "Failed to save image", callbackId: callbackId ) } } } } } func checkPhotoLibraryPermission(completion: @escaping (Bool) -> Void) { let status = PHPhotoLibrary.authorizationStatus() switch status { case .authorized, .limited: completion(true) case .notDetermined: PHPhotoLibrary.requestAuthorization { newStatus in DispatchQueue.main.async { completion(newStatus == .authorized || newStatus == .limited) } } default: completion(false) } } func sendResponse( success: Bool, data: [String: Any]? = nil, error: String? = nil, callbackId: String ) { var response: [String: Any] = [ "success": success, "callbackId": callbackId ] if let data = data { response["data"] = data } if let error = error { response["error"] = error } if let jsonData = try? JSONSerialization.data(withJSONObject: response), let jsonString = String(data: jsonData, encoding: .utf8) { webView.evaluateJavaScript("window.postMessage(\(jsonString), '*')") } } } ``` ### 3. Info.plist 권한 설정 ```xml NSPhotoLibraryAddUsageDescription 현금영수증 이미지를 저장하기 위해 사진 라이브러리 접근 권한이 필요합니다. NSPhotoLibraryUsageDescription 현금영수증 이미지를 저장하기 위해 사진 라이브러리 접근 권한이 필요합니다. ``` --- ## Android (Kotlin) 구현 예시 ### 1. WebView 인터페이스 설정 ```kotlin import android.webkit.WebView import android.webkit.JavascriptInterface import org.json.JSONObject class MainActivity : AppCompatActivity() { private lateinit var webView: WebView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) webView = findViewById(R.id.webView) webView.settings.javaScriptEnabled = true webView.addJavascriptInterface(WebAppInterface(this, webView), "AndroidBridge") } } class WebAppInterface( private val context: Context, private val webView: WebView ) { @JavascriptInterface fun postMessage(message: String) { try { val json = JSONObject(message) val type = json.getString("type") val callbackId = json.getString("callbackId") when (type) { "saveImage" -> { val data = json.optJSONObject("data") handleSaveImage(data, callbackId) } } } catch (e: Exception) { e.printStackTrace() } } } ``` ### 2. 이미지 저장 로직 구현 ```kotlin import android.Manifest import android.content.ContentValues import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build import android.provider.MediaStore import android.util.Base64 import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import org.json.JSONObject import java.io.OutputStream class WebAppInterface( private val context: Context, private val webView: WebView ) { companion object { private const val PERMISSION_REQUEST_CODE = 1001 } private fun handleSaveImage(data: JSONObject?, callbackId: String) { if (data == null) { sendResponse(false, null, "No data provided", callbackId) return } val imageDataString = data.optString("imageData") val fileName = data.optString("fileName", "image_${System.currentTimeMillis()}.png") if (imageDataString.isEmpty()) { sendResponse(false, null, "Invalid image data", callbackId) return } try { val imageBytes = Base64.decode(imageDataString, Base64.DEFAULT) val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) if (bitmap == null) { sendResponse(false, null, "Failed to decode image", callbackId) return } // Android 10 (Q) 이상 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { saveImageToGalleryQ(bitmap, fileName, callbackId) } else { // Android 9 이하 - 권한 확인 필요 if (checkStoragePermission()) { saveImageToGalleryLegacy(bitmap, fileName, callbackId) } else { requestStoragePermission() sendResponse(false, null, "Storage permission required", callbackId) } } } catch (e: Exception) { sendResponse(false, null, e.message ?: "Unknown error", callbackId) } } // Android 10 (Q) 이상 - Scoped Storage 사용 @RequiresApi(Build.VERSION_CODES.Q) private fun saveImageToGalleryQ(bitmap: Bitmap, fileName: String, callbackId: String) { val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) put(MediaStore.MediaColumns.MIME_TYPE, "image/png") put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/CashReceipts") } val resolver = context.contentResolver val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) if (imageUri == null) { sendResponse(false, null, "Failed to create image file", callbackId) return } try { val outputStream: OutputStream? = resolver.openOutputStream(imageUri) outputStream?.use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) sendResponse(true, JSONObject().apply { put("filePath", imageUri.toString()) }, null, callbackId) } ?: run { sendResponse(false, null, "Failed to open output stream", callbackId) } } catch (e: Exception) { sendResponse(false, null, e.message ?: "Failed to save image", callbackId) } } // Android 9 이하 - Legacy Storage 사용 private fun saveImageToGalleryLegacy(bitmap: Bitmap, fileName: String, callbackId: String) { val imagesDir = Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_PICTURES ) val imageFile = File(imagesDir, "CashReceipts/$fileName") try { imageFile.parentFile?.mkdirs() FileOutputStream(imageFile).use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } // 갤러리에 스캔하여 즉시 표시되도록 함 MediaScannerConnection.scanFile( context, arrayOf(imageFile.absolutePath), arrayOf("image/png"), null ) sendResponse(true, JSONObject().apply { put("filePath", imageFile.absolutePath) }, null, callbackId) } catch (e: Exception) { sendResponse(false, null, e.message ?: "Failed to save image", callbackId) } } private fun checkStoragePermission(): Boolean { return ContextCompat.checkSelfPermission( context, Manifest.permission.WRITE_EXTERNAL_STORAGE ) == PackageManager.PERMISSION_GRANTED } private fun requestStoragePermission() { if (context is Activity) { ActivityCompat.requestPermissions( context, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), PERMISSION_REQUEST_CODE ) } } private fun sendResponse( success: Boolean, data: JSONObject?, error: String?, callbackId: String ) { val response = JSONObject().apply { put("success", success) put("callbackId", callbackId) data?.let { put("data", it) } error?.let { put("error", it) } } (context as? Activity)?.runOnUiThread { val script = "window.postMessage(${response}, '*')" webView.evaluateJavascript(script, null) } } } ``` ### 3. AndroidManifest.xml 권한 설정 ```xml ``` --- ## 테스트 방법 ### 웹 콘솔에서 테스트 ```javascript // 네이티브 브릿지 테스트 if (window.appBridge?.isNativeEnvironment()) { console.log('Native environment detected'); // 테스트 이미지 저장 const testImageData = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; appBridge.saveImage({ imageData: testImageData, fileName: 'test.png', mimeType: 'image/png' }).then(result => { console.log('Save result:', result); }).catch(error => { console.error('Save error:', error); }); } ``` --- ## 주의사항 1. **권한 처리**: iOS와 Android 모두 사진 라이브러리 접근 권한이 필요합니다. 2. **에러 핸들링**: 네트워크 오류, 권한 거부, 저장소 부족 등의 경우를 처리해야 합니다. 3. **파일명 중복**: 동일한 파일명이 있을 경우 처리 로직이 필요합니다. 4. **메모리 관리**: 대용량 이미지의 경우 메모리 관리에 주의해야 합니다. 5. **Android Scoped Storage**: Android 10 이상에서는 Scoped Storage를 사용해야 합니다. --- ## 추가 개선 사항 1. **진행 상태 표시**: 대용량 이미지 저장 시 로딩 인디케이터 추가 2. **저장 위치 커스터마이징**: 사용자가 저장 위치를 선택할 수 있도록 개선 3. **공유 기능**: 저장 후 바로 공유할 수 있는 옵션 추가 4. **포맷 선택**: PNG 외에 JPEG 등 다른 포맷 지원 --- ## 문의 구현 중 문제가 발생하면 웹 개발팀에 문의해주세요.