/
InAppMessaging.kt
378 lines (340 loc) · 14.4 KB
/
InAppMessaging.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
//
// Copyright 2020 PLAID, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package io.karte.android.inappmessaging
import android.app.Activity
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Window
import android.webkit.CookieManager
import android.widget.PopupWindow
import io.karte.android.KarteApp
import io.karte.android.core.library.ActionModule
import io.karte.android.core.library.Library
import io.karte.android.core.library.UserModule
import io.karte.android.core.logger.Logger
import io.karte.android.inappmessaging.internal.IAMPresenter
import io.karte.android.inappmessaging.internal.IAMWebView
import io.karte.android.inappmessaging.internal.IAMWindow
import io.karte.android.inappmessaging.internal.MessageModel
import io.karte.android.inappmessaging.internal.PanelWindowManager
import io.karte.android.inappmessaging.internal.preview.PreviewParams
import io.karte.android.tracking.MessageEvent
import io.karte.android.tracking.MessageEventType
import io.karte.android.tracking.Tracker
import io.karte.android.tracking.client.TrackRequest
import io.karte.android.tracking.client.TrackResponse
import io.karte.android.utilities.ActivityLifecycleCallback
import org.json.JSONException
import org.json.JSONObject
import java.lang.ref.WeakReference
private const val LOG_TAG = "Karte.InAppMessaging"
private const val PREVENT_RELAY_TO_PRESENTER_KEY = "krt_prevent_relay_to_presenter"
private const val COOKIE_DOMAIN = "karte.io"
/**
* アプリ内メッセージの管理を行うクラスです。
*/
class InAppMessaging : Library, ActionModule, UserModule, ActivityLifecycleCallback() {
//region Library
override val name: String = "inappmessaging"
override val version: String = BuildConfig.LIB_VERSION
override val isPublic: Boolean = true
override fun configure(app: KarteApp) {
self = this
app.application.registerActivityLifecycleCallbacks(this)
this.app = app
app.register(this)
}
override fun unconfigure(app: KarteApp) {
self = null
app.application.unregisterActivityLifecycleCallbacks(this)
app.unregister(this)
// teardown
currentActiveActivity = null
presenter?.destroy()
isSuppressed = false
delegate = null
cachedWebView?.destroy()
cachedWebView = null
}
//endregion
//region ActionModule
override fun receive(trackResponse: TrackResponse, trackRequest: TrackRequest) {
uiThreadHandler.post {
try {
val message = MessageModel(trackResponse.json, trackRequest)
message.filter(app.pvId, ::trackMessageSuppressed)
if (!message.shouldLoad()) return@post
if (isSuppressed) {
message.messages.map {
trackMessageSuppressed(it, "The display is suppressed by suppress mode.")
}
return@post
}
val activity = currentActiveActivity?.get()
if (activity == null) {
message.messages.map {
trackMessageSuppressed(
it,
"The display is suppressed because Activity is not found."
)
}
return@post
}
Logger.d(LOG_TAG, "Try to add overlay to activity if not yet added. $activity")
if (!windowFocusable) windowFocusable = message.shouldFocusCrossDisplayCampaign()
setIAMWindow(message.shouldFocus())
presenter?.addMessage(message)
} catch (e: JSONException) {
Logger.d(LOG_TAG, "Failed to parse json. ", e)
}
}
}
override fun reset() {
Logger.d(LOG_TAG, "reset pv_id. ${app.pvId} ${app.originalPvId}")
// pvIdがある(onResumeより後ろ)場合のみdismissする
if (app.pvId != app.originalPvId) {
uiThreadHandler.post {
getWebView()?.also {
if (it.hasMessage) {
if (currentActiveActivity != null) {
setIAMWindow(windowFocusable)
}
it.handleChangePv()
it.reset(false)
} else {
Logger.d(LOG_TAG, "Dismiss by reset pv_id")
windowFocusable = false
dismiss()
}
}
}
}
}
override fun resetAll() {
dismiss()
}
//endregion
//region UserModule
override fun renewVisitorId(current: String, previous: String?) {
uiThreadHandler.post {
presenter?.destroy()
getWebView(generateOverlayURL())
clearWebViewCookies()
}
}
//endregion
//region ActivityLifecycleCallback
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
currentActiveActivity = WeakReference(activity)
}
override fun onActivityStarted(activity: Activity) {
val previewParams = PreviewParams(activity)
// 接客プレビューの場合はイベントを送信しない。
if (previewParams.shouldShowPreview()) {
Logger.i(LOG_TAG, "Enter preview mode. ${previewParams.generateUrl(app)}")
app.optOutTemporarily()
showPreview(previewParams)
}
}
override fun onActivityResumed(activity: Activity) {
currentActiveActivity = WeakReference(activity)
// foregroundになった時に初めてキャッシュする.
getWebView()
}
override fun onActivityPaused(activity: Activity) {
// FileChooser等のrelay以外は非表示にする.
var isPreventRelayToPresenter = false
activity.intent?.let { intent ->
isPreventRelayToPresenter =
intent.getBooleanExtra(PREVENT_RELAY_TO_PRESENTER_KEY, false)
intent.removeExtra(PREVENT_RELAY_TO_PRESENTER_KEY)
}
Logger.d(LOG_TAG, "onActivityPaused prevent_relay flag: $isPreventRelayToPresenter")
if (!isPreventRelayToPresenter) presenter?.destroy(false)
currentActiveActivity = null
}
//endregion
internal fun enablePreventRelayFlag(activity: Activity?) {
activity?.intent?.putExtra(PREVENT_RELAY_TO_PRESENTER_KEY, true)
}
companion object {
internal var self: InAppMessaging? = null
/**
* アプリ内メッセージの表示有無を返します。
*
* アプリ内メッセージが表示中の場合は `true` を返し、表示されていない場合は `false` を返します。
*/
@JvmStatic
val isPresenting: Boolean
get() = self?.presenter?.isVisible == true
/**
* アプリ内メッセージで発生するイベント等を委譲するためのデリゲートインスタンスを取得・設定します。
*/
@JvmStatic
var delegate: InAppMessagingDelegate?
get() = self?.delegate
set(value) {
self?.delegate = value
}
/**
* 現在表示中の全てのアプリ内メッセージを非表示にします。
*/
@JvmStatic
fun dismiss() {
self?.uiThreadHandler?.post {
self?.presenter?.destroy()
}
}
/**
* アプリ内メッセージの表示を抑制します。
*
* なお既に表示されているアプリ内メッセージは、メソッドの呼び出しと同時に非表示となります。
*/
@JvmStatic
fun suppress() {
self?.isSuppressed = true
dismiss()
}
/**
* アプリ内メッセージの表示抑制状態を解除します。
*/
@JvmStatic
fun unsuppress() {
self?.isSuppressed = false
}
/**
* アプリ内で保持している[PopupWindow]を渡します。
* SDKはアプリ内メッセージ表示中に、渡された[PopupWindow]の状態に応じてタップの透過等を行ないます。
*
* @param[popupWindow] [PopupWindow]
*/
@JvmStatic
fun registerPopupWindow(popupWindow: PopupWindow) {
self?.panelWindowManager?.registerPopupWindow(popupWindow)
}
/**
* アプリ内で保持している[TYPE_APPLICATION_PANEL][android.view.WindowManager.LayoutParams#TYPE_APPLICATION_PANEL]タイプの[Window]を渡します。
* SDKはアプリ内メッセージ表示中に、渡された[Window]の状態に応じてタップの透過等を行ないます。
*
* @param[window] [Window]
*/
@JvmStatic
fun registerWindow(window: Window) {
self?.panelWindowManager?.registerWindow(window)
}
}
/**InAppMessagingモジュールの設定を保持するクラスです。*/
object Config {
/**
* 接客用WebViewのキャッシュの有無の取得・設定を行います。
*
* `true` の場合はキャッシュが有効となり、`false` の場合は無効となります。デフォルトは `true` です。
*/
@JvmStatic
@Deprecated("This param is always true")
var enabledWebViewCache = true
}
internal lateinit var app: KarteApp
private val uiThreadHandler: Handler = Handler(Looper.getMainLooper())
private val panelWindowManager = PanelWindowManager()
private val overlayBaseUrl = "https://cf-native.karte.io/v0/native"
private var currentActiveActivity: WeakReference<Activity>? = null
private var presenter: IAMPresenter? = null
private var isSuppressed = false
private var delegate: InAppMessagingDelegate? = null
private var cachedWebView: IAMWebView? = null
private var windowFocusable: Boolean = false
/*
* WebViewを作成. キャッシュ有効時には初回はキャッシュを作成し、以後使い回す。
*/
private fun getWebView(url: String? = null): IAMWebView? {
cachedWebView?.let {
// urlが指定されている場合、読み込み済みのものと異なればcacheを使用しない
if (url == null || url == it.url)
return it
}
Logger.d(LOG_TAG, "WebView recreate")
try {
cachedWebView = IAMWebView(app.application) { uri: Uri ->
Boolean
Logger.d(LOG_TAG, " shouldOpenURL $delegate")
delegate?.shouldOpenURL(uri) ?: true
}.apply { loadUrl(url ?: generateOverlayURL()) }
} catch (e: PackageManager.NameNotFoundException) {
// WebViewアップデート中に初期化すると例外発生する可能性がある
// NOTE: https://stackoverflow.com/questions/29575313/namenotfoundexception-webview
// 4系,5.0系に多いが、その他でも発生しうる。
Logger.e(LOG_TAG, "Failed to construct IAMWebView, because WebView is updating.", e)
} catch (t: Throwable) {
// 7系等入っているWebViewによってWebKit側のExceptionになってしまうのでThrowableでキャッチする
// https://stackoverflow.com/questions/46278681/android-webkit-webviewfactorymissingwebviewpackageexception-from-android-7-0
Logger.e(LOG_TAG, "Failed to construct IAMWebView", t)
}
return cachedWebView
}
private fun setIAMWindow(focusable: Boolean) {
if (presenter != null) return
val activity = currentActiveActivity?.get() ?: return
val webView = getWebView() ?: return
Logger.d(LOG_TAG, "Setting IAMWindow to activity. $currentActiveActivity")
presenter = IAMPresenter(
IAMWindow(activity, panelWindowManager, webView).apply { setFocus(focusable) },
webView
) { presenter = null }
}
private fun generateOverlayURL(): String {
return "$overlayBaseUrl/overlay?app_key=${app.appKey}&_k_vid=${KarteApp.visitorId}" +
"&_k_app_prof=${app.appInfo?.json}"
}
private fun clearWebViewCookies() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val cookieManager = CookieManager.getInstance()
val allCookies = cookieManager.getCookie(COOKIE_DOMAIN) ?: return
allCookies
.split("; ")
.filter { !it.isBlank() && it.contains("=") }
.forEach {
val cookieString = it.substringBefore("=") + "=; Domain=" + COOKIE_DOMAIN
cookieManager.setCookie(COOKIE_DOMAIN, cookieString)
}
cookieManager.flush()
}
}
private fun showPreview(params: PreviewParams) {
currentActiveActivity?.get()?.window?.decorView?.post {
val activity = currentActiveActivity?.get() ?: return@post
val url = params.generateUrl(app) ?: return@post
val webView = getWebView(url) ?: return@post
// dismissされないため. 本来はTracker.jsサイドで処理する?
webView.hasMessage = true
presenter = IAMPresenter(
IAMWindow(activity, panelWindowManager, webView),
webView
) { presenter = null }
}
}
private fun trackMessageSuppressed(message: JSONObject, reason: String) {
val action = message.getJSONObject("action")
val campaignId = action.getString("campaign_id")
val shortenId = action.getString("shorten_id")
val values = mapOf("reason" to reason)
Tracker.track(MessageEvent(MessageEventType.Suppressed, campaignId, shortenId, values))
}
}