# 네이티브 앱 이미지 저장 기능 구현 가이드

## 개요
웹뷰에서 생성한 이미지(현금영수증 등)를 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
<key>NSPhotoLibraryAddUsageDescription</key>
<string>현금영수증 이미지를 저장하기 위해 사진 라이브러리 접근 권한이 필요합니다.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>현금영수증 이미지를 저장하기 위해 사진 라이브러리 접근 권한이 필요합니다.</string>
```

---

## 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
<!-- Android 9 이하용 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />
```

---

## 테스트 방법

### 웹 콘솔에서 테스트
```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 등 다른 포맷 지원

---

## 문의
구현 중 문제가 발생하면 웹 개발팀에 문의해주세요.
