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