- iOS 환경에서 이미지 다운로드가 안 되는 문제 해결 - 네이티브 앱 브릿지를 통한 이미지 저장 기능 구현 - iOS만 네이티브 브릿지 사용, Android/웹은 기존 방식 유지 변경사항: - BridgeMessageType에 SAVE_IMAGE 타입 추가 - SaveImageRequest, SaveImageResponse 인터페이스 정의 - appBridge.saveImage() 메서드 구현 - cash-receipt-sample.tsx에서 iOS 환경 감지 및 네이티브 저장 처리 - 네이티브 앱 개발자를 위한 구현 가이드 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
14 KiB
14 KiB
네이티브 앱 이미지 저장 기능 구현 가이드
개요
웹뷰에서 생성한 이미지(현금영수증 등)를 iOS/Android 네이티브 환경에서 갤러리에 저장하는 기능 구현 가이드입니다.
웹 → 네이티브 통신 스펙
메시지 타입
type: 'saveImage'
요청 데이터 구조
{
type: 'saveImage',
data: {
imageData: string, // base64 인코딩된 이미지 데이터 (순수 base64, data URL 헤더 제외)
fileName?: string, // 파일명 (optional, 기본값: timestamp)
mimeType?: string // MIME 타입 (optional, 기본값: 'image/png')
},
callbackId: string // 콜백 식별자
}
응답 데이터 구조
{
success: boolean, // 성공 여부
data?: {
filePath?: string // 저장된 파일 경로 (optional)
},
error?: string, // 에러 메시지 (실패 시)
callbackId: string // 요청 시 받은 callbackId
}
iOS (Swift) 구현 예시
1. WKWebView 메시지 핸들러 등록
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. 이미지 저장 로직 구현
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 권한 설정
<key>NSPhotoLibraryAddUsageDescription</key>
<string>현금영수증 이미지를 저장하기 위해 사진 라이브러리 접근 권한이 필요합니다.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>현금영수증 이미지를 저장하기 위해 사진 라이브러리 접근 권한이 필요합니다.</string>
Android (Kotlin) 구현 예시
1. WebView 인터페이스 설정
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. 이미지 저장 로직 구현
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 권한 설정
<!-- 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" />
테스트 방법
웹 콘솔에서 테스트
// 네이티브 브릿지 테스트
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);
});
}
주의사항
- 권한 처리: iOS와 Android 모두 사진 라이브러리 접근 권한이 필요합니다.
- 에러 핸들링: 네트워크 오류, 권한 거부, 저장소 부족 등의 경우를 처리해야 합니다.
- 파일명 중복: 동일한 파일명이 있을 경우 처리 로직이 필요합니다.
- 메모리 관리: 대용량 이미지의 경우 메모리 관리에 주의해야 합니다.
- Android Scoped Storage: Android 10 이상에서는 Scoped Storage를 사용해야 합니다.
추가 개선 사항
- 진행 상태 표시: 대용량 이미지 저장 시 로딩 인디케이터 추가
- 저장 위치 커스터마이징: 사용자가 저장 위치를 선택할 수 있도록 개선
- 공유 기능: 저장 후 바로 공유할 수 있는 옵션 추가
- 포맷 선택: PNG 외에 JPEG 등 다른 포맷 지원
문의
구현 중 문제가 발생하면 웹 개발팀에 문의해주세요.