안드로이드(JavaScript)

안드로이드 환경에서 JavaScript만을 사용해 채널톡 SDK를 사용하기 위해서는 아래와 같은 설정이 필요합니다.

🚧

JavaScript SDK만 사용하는 경우, 푸시 알림 기능을 지원하지 않습니다.

푸시 알림 기능을 사용하려면 안드로이드(SDK)를 참고합니다.

새 탭 허용하기

안드로이드 웹뷰는 기본적으로 새 탭을 허용하지 않기 때문에, 이를 허용하기 위한 코드를 추가로 작성해야 합니다.

webChromeClient = object : WebChromeClient() {
    override fun onCreateWindow(
        view: WebView?,
        isDialog: Boolean,
        isUserGesture: Boolean,
        resultMsg: Message?
    ): Boolean {
        // intercept new window
        val transport = resultMsg?.obj as? WebView.WebViewTransport
        val newWebView = WebView(this@WebViewActivity)

        view?.addView(newWebView)
        transport?.webView = newWebView
        resultMsg?.sendToTarget()

        newWebView.webViewClient = object : WebViewClient() {
            // handle url navigating and create new activity
            override fun shouldOverrideUrlLoading(
                    view: WebView,
                    url: String
            ): Boolean {
                return when {
                    url.startsWith("tel:") -> {
                        val intent = Intent(Intent.ACTION_DIAL, Uri.parse(url))
                        startActivity(intent)
                        true
                    }
                    url.startsWith("mailto:") -> {
                        val intent = Intent(Intent.ACTION_SENDTO, Uri.parse(url))
                        startActivity(intent)
                        true
                    }
                    else -> {
                        Intent(view.context, WebViewActivity::class.java)
                            .apply { putExtra("url", url) }
                            .also { intent -> startActivity(intent) }
                        true
                    }
                }
            }
        }

        return true
    }
}

파일 업로드

안드로이드 웹뷰는 기본적으로 파일 선택 동작을 구현해놓지 않기 때문에, 이를 구현하는 코드를 추가로 작성해야 합니다.

📘

모바일 환경에서 조금 더 쉽고 직관적으로 채널톡 SDK를 활용하기를 원한다면 안드로이드 SDK를 사용할 것을 권장합니다.

  1. 웹뷰가 파일에 접근하는 것을 허용해준 후, 유저가 파일 선택기를 열고자 하는 순간을 감지할 수 있도록 WebChromeClient를 설정합니다.
yourWebViewInstance.apply {
    settings.apply {
        // -- your other WebView options --

        // add this line to make WebView to access file system
        allowFileAccess = true
    }

    webChromeClient = object : WebChromeClient() {
        override fun onShowFileChooser(
                webView: WebView,
                filePathCallback: ValueCallback<Array<Uri>>,
                fileChooserParams: FileChooserParams
        ): Boolean {
                // -- snip --
        }
}
  1. 아래 WebViewAttachmentModule 클래스를 복사해 사용합니다. WebViewAttachmentModule는 파일 선택기 콜백을 동작시킵니다.

🚧

onShowFileChooser는 true를 반환(return)해야 합니다.

WebChromeClientonShowFileChooserfalse를 반환하는 경우 filePathCallback이 호출되는 것을 허용하지 않습니다. getIntentChooser는 항상 filePathCallback을 호출하기 때문에 onShowFileChoosertrue를 반환할 때만 getIntentChooser를 호출해야 합니다.

class WebViewAttachmentModule {

    private var filePathCallback: ValueCallback<Array<Uri>>? = null

    fun getIntentChooser(filePathCallback: ValueCallback<Array<Uri>>, acceptTypes: Array<String>): Intent {
                // init filePathCallback
        this.filePathCallback?.onReceiveValue(null)
        this.filePathCallback = filePathCallback

        return Intent(Intent.ACTION_GET_CONTENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = "*/*"
            putExtra(Intent.EXTRA_MIME_TYPES, getMimeTypesFromAcceptTypes(acceptTypes))
        }
    }

        // handle onActivityResult process in activity
    fun handleResult(requestCode: Int, resultCode: Int, data: Intent?) {
        val uri = data?.data
        if (requestCode == WEB_VIEW_FILE_UPLOAD_REQUEST_CODE && resultCode == AppCompatActivity.RESULT_OK && uri != null) {
            filePathCallback?.onReceiveValue(arrayOf(uri))
        } else {
            filePathCallback?.onReceiveValue(null)
        }
        filePathCallback = null
    }

    private fun getMimeTypesFromAcceptTypes(acceptTypes: Array<String>): Array<String> {
        return (acceptTypes
                .mapNotNull { acceptType ->
                    when {
                        // Seperate case of extensions and MIME Type
                        acceptType.startsWith(".") -> MimeTypeMap.getSingleton().getMimeTypeFromExtension(acceptType.substring(1))
                        else -> acceptType
                    }
                }
                // setting "*/*" when acceptType is not exist
                .filter { it.isNotBlank() }
                .takeIf { it.isNotEmpty() }
                ?: listOf("*/*"))
                .toTypedArray()
    }
}

파일 다운로드

안드로이드 웹뷰는 기본적으로 파일 다운로드 동작이 구현되어 있지 않으므로 이를 구현하는 코드를 추가로 작성해야 합니다.

  1. 아래를 참고하여 다운로드를 담당하는 리스너를 작성합니다.
class WebViewDownloadListener(
        private val context: Context,
) : DownloadListener {
    override fun onDownloadStart(url: String?, userAgent: String?, contentDisposition: String?, mimetype: String?, contentLength: Long) {
        val uri = Uri.parse(url)

        val fileName = URLUtil.guessFileName(url, contentDisposition, mimetype)
        val request = DownloadManager.Request(uri)

        val cookie = CookieManager.getInstance().getCookie(url)
        request.addRequestHeader("Cookie", cookie)
        request.addRequestHeader("User-Agent", userAgent)

        request.setTitle(fileName)

        request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
        request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)

        val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager

        downloadManager?.enqueue(request)
    }
}
  1. 작성한 리스너 객체를 웹뷰 객체에 적용합니다. 예시는 아래와 같습니다.
yourWebViewInstance.setDownloadListener(WebViewDownloadListener(context))

예시 코드

🚧

참고 코드이므로 상황에 맞게 변경해 사용하는 것을 추천합니다.

class WebViewActivity : AppCompatActivity() {
  
    private val attachmentModule = WebViewAttachmentModule()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_web_view)

        webview.webViewClient = WebViewClient()

        val url = intent.getStringExtra("url") ?: SOME_DEFAULT_URL

        webview.apply {
            // 파일 다운로드 로직 설정
            setDownloadListener(WebViewDownloadListener(context))

            settings.apply {
                // 웹뷰 설정
                javaScriptEnabled = true
                domStorageEnabled = true
                useWideViewPort = true
                allowFileAccess = true

                setSupportMultipleWindows(true)
                javaScriptCanOpenWindowsAutomatically = true
            }

            webChromeClient = object : WebChromeClient() {
                // 파일 업로드 처리
                override fun onShowFileChooser(
                        webView: WebView,
                        filePathCallback: ValueCallback<Array<Uri>>,
                        fileChooserParams: FileChooserParams
                ): Boolean {
                    startActivityForResult(
                            attachmentModule.getIntentChooser(
                                    filePathCallback,
                                    fileChooserParams.acceptTypes,
                            ),
                            Const.WEB_VIEW_FILE_UPLOAD_REQUEST_CODE,
                    ) // 여기서 true를 반환할 때만 getIntentChooser()를 호출해야 합니다.
                    return true
                }
              
                override fun onCreateWindow(
                    view: WebView?,
                    isDialog: Boolean,
                    isUserGesture: Boolean,
                    resultMsg: Message?
                ): Boolean {
                    // 새로운 윈도우 관련 처리
                    val transport = resultMsg?.obj as? WebView.WebViewTransport
                    val newWebView = WebView(this@WebViewActivity)

                    view?.addView(newWebView)
                    transport?.webView = newWebView
                    resultMsg?.sendToTarget()

                    newWebView.webViewClient = object : WebViewClient() {
                        // URL 이동 관련 처리
                        override fun shouldOverrideUrlLoading(
                                view: WebView,
                                url: String
                        ): Boolean {
                            return when {
                                url.startsWith("tel:") -> {
                                    val intent = Intent(Intent.ACTION_DIAL, Uri.parse(url))
                                    startActivity(intent)
                                    true
                                }
                                url.startsWith("mailto:") -> {
                                    val intent = Intent(Intent.ACTION_SENDTO, Uri.parse(url))
                                    startActivity(intent)
                                    true
                                }
                                else -> {
                                    Intent(view.context, WebViewActivity::class.java)
                                        .apply { putExtra("url", url) }
                                        .also { intent -> startActivity(intent) }
                                    true
                                }
                            }
                        }
                    }

                    return true
                }
            }
        }

        webview.loadUrl(url)
    }
  
    // handle file upload
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        attachmentModule.handleResult(requestCode, resultCode, data)

        super.onActivityResult(requestCode, resultCode, data)
    }
}

안드로이드(SDK)

웹뷰 안에 JavaScript SDK를 내장시키는 방식을 사용하지 않고 안드로이드 SDK를 사용하는 경우 아래와 같은 방법을 사용할 수 있습니다. 안드로이드 SDK를 사용할 경우, 푸시 알림 기능을 함께 사용할 수 있습니다. 안드로이드 SDK 시작하기푸시 알림을 참고합니다.

❗️

채널톡 SDK의 UI는 하나만 표시해야 합니다.

이미 채널톡 JavaScript SDK와 Android SDK를 동시에 사용하고 있다면 JavaScript SDK Boot OptionhideChannelButtonOnBoothidePopup=true로 설정하여 채널톡 버튼과 팝업을 숨겨야 합니다. 안드로이드 SDK와 JavaScript SDK를 동시에 사용하는 경우에는 같은 UI가 화면에 중복 표시될 수 있습니다.

안드로이드 SDK가 현재 URL 정보를 알 수 있도록, setPage를 사용하기를 권장합니다. 안드로이드 SDK는 Page 정보를 통해 필요한 이벤트를 발생시키거나, 워크플로우를 동작시킵니다.

class WebViewActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
 
        ...

        webview.apply {
            settings.apply {
                // 옵션 설정
            }

            webViewClient = object : WebViewClient() {
                override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
                    // 현재 페이지를 SDK가 알 수 있도록 합니다
                    ChannelIO.setPage(url)
                }
            }
        }

        webview.loadUrl(url)
    }
}

iOS

이 단락에서는 iOS WebView 환경에서 모바일 웹에 설치된 JavaScript SDK를 사용하는 방법을 서술합니다.

🚧

JavaScript만 사용하는 경우, 푸시 알림 기능을 지원하지 않습니다.

푸시 알림 기능을 사용하려면 ChannelIO iOS SDK를 참고하세요.

아래 예시는 임의의 WebView ViewController에 url이 YOUR_WEB_URL 인 페이지를 로드하는 예제입니다.

import UIKit
import WebKit
import Photos

class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
enum FileFormat: String {
    case image
    case video
    case file
    
    init(rawValue: String) {
      switch rawValue {
      case "image": self = .image
      case "video": self = .video
      default: self = .file
      }
    }
  }

  var webView: WKWebView!
  var url: String = "YOUR_WEB_URL"
  
  override func loadView() {
    let config = WKWebViewConfiguration()
    config.allowsInlineMediaPlayback = true  // # 1. modify to true inlineMediaPlay option
    
    webView = WKWebView(frame: .zero, configuration: config)
    webView.uiDelegate = self
    webView.navigationDelegate = self

    view = webView  
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    let myURL = URL(string:self.url)
    let myRequest = URLRequest(url: self.myURL!)
    self.webView.load(myRequest)
    self.setupActivityIndicator()

  }
  
  // #2. target blank handling
  func webView(
    _ webView: WKWebView, 
    createWebViewWith configuration: WKWebViewConfiguration, 
    for navigationAction: WKNavigationAction, 
    windowFeatures: WKWindowFeatures) -> WKWebView? {
    if navigationAction.targetFrame == nil {
      webView.load(navigationAction.request)
    }
    return nil
  }
  
  // #3. 파일, 사진 / 동영상 다운로드 처리.
  func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationResponse: WKNavigationResponse,
    decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
  ) {
    guard
      let response = navigationResponse.response as? HTTPURLResponse,
      let url = response.url,
      url.pathComponents.contains(where: { $0 == "pri-file"}),
      response.statusCode == 200,
      let mimeType = response.mimeType?.split(separator: "/").first,
      let contentDisposition = response.allHeaderFields["Content-Disposition"] as? String,
      contentDisposition.contains("attachment;")
    else {
      decisionHandler(.allow)
      return
    }
    
    let fileExtension = contentDisposition.components(separatedBy: ".").last ?? ""
    let fileFormat = FileFormat(rawValue: String(mimeType))
    
    self.downloadFile(from: url, fileExtension: String(fileExtension)) { [weak self] result in
      switch result {
      case .success(let url):
        switch fileFormat {
        case .image, .video:
          self?.saveToPhotoLibrary(fileURL: url, format: fileFormat) { [weak self] _ in
            self?.hideActivityIndicator()
          }
        case .file:
          DispatchQueue.main.async { [weak self] in
            let controller = UIActivityViewController(
              activityItems: [url],
              applicationActivities: nil
            )
            self?.present(controller, animated: true)
          }
        }
      case .failure(let error):
        print("file download was ended with failure. \(error.localizedDescription)")
      }
      
      if #available(iOS 14.5, *) {
        decisionHandler(.download)
      } else {
        decisionHandler(.allow)
      }
    }
  }
}

// #3. 파일, 사진 / 동영상 다운로드 처리
extension ViewController {
  private func setupActivityIndicator() {
    self.view.addSubview(self.indicator)
    
    NSLayoutConstraint.activate([
      self.indicator.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
      self.indicator.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
    ])
  }
  
  private func showActivityIndicator() {
    DispatchQueue.main.async {
      self.indicator.isHidden = false
      self.indicator.startAnimating()
    }
  }
  
  private func hideActivityIndicator() {
    DispatchQueue.main.async {
      self.indicator.isHidden = true
      self.indicator.stopAnimating()
    }
  }
  
  private func downloadFile(
    from url: URL,
    fileExtension: String,
    completion: @escaping (Result<URL, Error>) -> ()
  ) {
    URLSession.shared.downloadTask(with: url) { location, response, error in
      guard let location, error == nil else {
        completion(.failure(error ?? NSError(domain: "failed to download.", code: -2) ))
        return
      }

      let fileManager = FileManager.default
      let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
      let destinationURL = documentsDirectory.appendingPathComponent(url.lastPathComponent + "." + fileExtension)
      
      do {
        if fileManager.fileExists(atPath: destinationURL.path) {
          try fileManager.removeItem(at: destinationURL)
        }
        
        try fileManager.moveItem(at: location, to: destinationURL)
        completion(.success(destinationURL))
      } catch let error {
        completion(.failure(error))
      }
    }
    .resume()
  }

  private func saveToPhotoLibrary(
    fileURL: URL,
    format: FileFormat,
    completion: @escaping (Result<Void, Error>) -> ()
  ) {
    PHPhotoLibrary.requestAuthorization { status in
      guard status == .authorized else {
        completion(.failure(NSError(domain: "Failed to require photoLibrary permission", code: -1)))
        return
      }
      
      PHPhotoLibrary.shared().performChanges({
        switch format {
        case .image: PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: fileURL)
        case .video: PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: fileURL)
        case .file:
          completion(.failure(NSError(domain: "cannot handle file type assets.", code: -3)))
        }
      }) { success, error in
        if success {
          completion(.success(()))
        } else if let error {
          completion(.failure(error))
        }
      }
    }
  }
}

필요한 추가 처리

위에 기술한 코드 예시에 이미 각각 #1, #2, #3 표시가 되어 있습니다.

# 1. allowsInlineMediaPlayback

iOS WKWebView의 allowsInlineMediaPlayback은 HTML5 video를 인라인으로 재생할 것인지, 전체화면으로 재생할 것인지에 대한 설정값입니다. default는 false입니다. 따라서 SDK 내부에 비디오가 있는 경우에 자동으로 전체화면으로 재생합니다.

이를 수정하기 위해 allowsInlineMediaPlayback 을 true 로 설정합니다.

# 2. target blank handling

채널톡 JavaScript SDK 링크의 속성은 모두 target="_blank"으로, 해당 링크를 새탭으로 열게 됩니다. 그러나, iOS의 WKWebView는 새 탭에 대한 처리가 되어있지 않아 이에 대한 처리가 필요합니다.

해당 처리는target="_blank"에 대한 대한 액션인 경우 URL을 받아와, WebView에 URL을 로드하는 방식으로 해결합니다.

# 3. 파일, 사진 / 동영상 다운로드 처리

iOS WKWebView는 파일, 사진 / 동영상을 다운로드 받기 위해 별도의 처리가 필요합니다. 해당 코드는 웹뷰에서 발생한 URL 처리 중, 채널톡의 이미지 / 동영상인 경우 적절히 파일을 다운로드 하는 로직을 포함합니다.