diff --git a/NATIVE_IMAGE_SAVE_GUIDE.md b/NATIVE_IMAGE_SAVE_GUIDE.md
new file mode 100644
index 0000000..6ca8756
--- /dev/null
+++ b/NATIVE_IMAGE_SAVE_GUIDE.md
@@ -0,0 +1,448 @@
+# 네이티브 앱 이미지 저장 기능 구현 가이드
+
+## 개요
+웹뷰에서 생성한 이미지(현금영수증 등)를 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 등 다른 포맷 지원
+
+---
+
+## 문의
+구현 중 문제가 발생하면 웹 개발팀에 문의해주세요.
diff --git a/src/entities/common/ui/cash-receipt-sample.tsx b/src/entities/common/ui/cash-receipt-sample.tsx
index 71f0917..851e32a 100644
--- a/src/entities/common/ui/cash-receipt-sample.tsx
+++ b/src/entities/common/ui/cash-receipt-sample.tsx
@@ -1,5 +1,5 @@
import { IMAGE_ROOT } from '@/shared/constants/common';
-import { snackBar } from '@/shared/lib';
+import { snackBar, appBridge } from '@/shared/lib';
import { toPng } from 'html-to-image';
import { useTranslation } from 'react-i18next';
import '@/shared/ui/assets/css/style-tax-invoice.css';
@@ -31,17 +31,46 @@ export const CashReceiptSample = ({
}: CashReceiptSampleProps) => {
const { t } = useTranslation();
- const downloadImage = () => {
+ const downloadImage = async () => {
const section = document.getElementById('image-section') as HTMLElement;
- toPng(section).then((image) => {
- const link = document.createElement('a');
- link.download = 'downloadImage.png';
- link.href = image;
- link.click();
- snackBar(t('common.imageRequested'), function(){
- //onClickToClose();
- });
- });
+
+ try {
+ const imageDataUrl = await toPng(section);
+
+ // iOS 네이티브 환경인 경우 네이티브 브릿지 사용
+ if (appBridge.isIOS()) {
+ try {
+ // data:image/png;base64, 부분 제거하고 순수 base64 데이터만 추출
+ const base64Data = imageDataUrl.split(',')[1];
+ const fileName = `cash_receipt_${moment().format('YYYYMMDDHHmmss')}.png`;
+
+ const result = await appBridge.saveImage({
+ imageData: base64Data,
+ fileName: fileName,
+ mimeType: 'image/png'
+ });
+
+ if (result.success) {
+ snackBar(t('common.imageSaved'));
+ } else {
+ throw new Error(result.error || 'Failed to save image');
+ }
+ } catch (error) {
+ console.error('Native image save failed:', error);
+ snackBar(t('common.imageSaveFailed'));
+ }
+ } else {
+ // Android 또는 웹 환경인 경우 기존 방식 사용 (다운로드 링크)
+ const link = document.createElement('a');
+ link.download = `cash_receipt_${moment().format('YYYYMMDDHHmmss')}.png`;
+ link.href = imageDataUrl;
+ link.click();
+ snackBar(t('common.imageRequested'));
+ }
+ } catch (error) {
+ console.error('Image generation failed:', error);
+ snackBar(t('common.imageGenerationFailed'));
+ }
};
const onClickToClose = () => {
setCashReceiptSampleOn(false);
diff --git a/src/shared/lib/appBridge.ts b/src/shared/lib/appBridge.ts
index 08f4cc2..bb79192 100644
--- a/src/shared/lib/appBridge.ts
+++ b/src/shared/lib/appBridge.ts
@@ -3,7 +3,9 @@ import {
AppBridgeResponse,
BridgeMessageType,
DeviceInfo,
- ShareContent
+ ShareContent,
+ SaveImageRequest,
+ SaveImageResponse
} from '@/types';
class AppBridge {
@@ -171,6 +173,11 @@ class AppBridge {
return this.sendMessage(BridgeMessageType.UPDATE_ALARM_COUNT, { count });
}
+ // 이미지 저장 (iOS/Android 네이티브)
+ async saveImage(request: SaveImageRequest): Promise {
+ return this.sendMessage(BridgeMessageType.SAVE_IMAGE, request);
+ }
+
// 네이티브 환경 체크
isNativeEnvironment(): boolean {
return !!(
diff --git a/src/types/bridge.ts b/src/types/bridge.ts
index 5279adb..4754a4c 100644
--- a/src/types/bridge.ts
+++ b/src/types/bridge.ts
@@ -82,7 +82,10 @@ export enum BridgeMessageType {
SET_NOTIFICATION_SETTING = 'setNotificationSetting',
// 알림 링크 확인
- CHECK_ALARM_LINK = 'checkAlarmLink'
+ CHECK_ALARM_LINK = 'checkAlarmLink',
+
+ // 이미지 저장
+ SAVE_IMAGE = 'saveImage'
}
export interface DeviceInfo {
@@ -118,4 +121,16 @@ export interface NotificationSettingResponse {
enabled: boolean;
needsPermission?: boolean;
message?: string;
+}
+
+export interface SaveImageRequest {
+ imageData: string; // base64 encoded image data
+ fileName?: string; // 파일명 (optional, default: timestamp)
+ mimeType?: string; // MIME 타입 (optional, default: 'image/png')
+}
+
+export interface SaveImageResponse {
+ success: boolean;
+ filePath?: string; // 저장된 파일 경로
+ error?: string;
}
\ No newline at end of file