안드로이드(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를 사용할 것을 권장합니다.
- 웹뷰가 파일에 접근하는 것을 허용해준 후, 유저가 파일 선택기를 열고자 하는 순간을 감지할 수 있도록
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 --
}
}
- 아래
WebViewAttachmentModule
클래스를 복사해 사용합니다.WebViewAttachmentModule
는 파일 선택기 콜백을 동작시킵니다.
onShowFileChooser
는 true를 반환(return)해야 합니다.
WebChromeClient
는onShowFileChooser
가false
를 반환하는 경우filePathCallback
이 호출되는 것을 허용하지 않습니다.getIntentChooser
는 항상filePathCallback
을 호출하기 때문에onShowFileChooser
가true
를 반환할 때만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()
}
}
파일 다운로드
안드로이드 웹뷰는 기본적으로 파일 다운로드 동작이 구현되어 있지 않으므로 이를 구현하는 코드를 추가로 작성해야 합니다.
- 아래를 참고하여 다운로드를 담당하는 리스너를 작성합니다.
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)
}
}
- 작성한 리스너 객체를 웹뷰 객체에 적용합니다. 예시는 아래와 같습니다.
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 Option의 hideChannelButtonOnBoot와 hidePopup=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 처리 중, 채널톡의 이미지 / 동영상인 경우 적절히 파일을 다운로드 하는 로직을 포함합니다.