-
-
Notifications
You must be signed in to change notification settings - Fork 227
/
WebViewFragment.kt
237 lines (212 loc) · 9.84 KB
/
WebViewFragment.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
package org.jellyfin.mobile.fragment
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import androidx.activity.addCallback
import androidx.core.view.ViewCompat
import androidx.core.view.doOnNextLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.add
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.webkit.WebResourceErrorCompat
import androidx.webkit.WebViewClientCompat
import androidx.webkit.WebViewFeature
import kotlinx.coroutines.launch
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.mobile.MainActivity
import org.jellyfin.mobile.R
import org.jellyfin.mobile.bridge.ExternalPlayer
import org.jellyfin.mobile.bridge.NativeInterface
import org.jellyfin.mobile.bridge.NativePlayer
import org.jellyfin.mobile.bridge.NativePlayerHost
import org.jellyfin.mobile.controller.ServerController
import org.jellyfin.mobile.databinding.FragmentWebviewBinding
import org.jellyfin.mobile.player.PlayerFragment
import org.jellyfin.mobile.utils.*
import org.jellyfin.mobile.utils.Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER_ID
import org.jellyfin.mobile.utils.Constants.FRAGMENT_WEB_VIEW_EXTRA_URL
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import org.json.JSONException
import org.json.JSONObject
import org.koin.android.ext.android.inject
import timber.log.Timber
import java.io.Reader
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class WebViewFragment : Fragment(), NativePlayerHost {
val apiClient: ApiClient by inject()
private val serverController: ServerController by inject()
private val webappFunctionChannel: WebappFunctionChannel by inject()
private val externalPlayer by lazy { ExternalPlayer(this) }
private var serverId: Long = 0
private lateinit var instanceUrl: String
private var connected = false
// UI
private var _webViewBinding: FragmentWebviewBinding? = null
private val webViewBinding get() = _webViewBinding!!
val webView: WebView get() = webViewBinding.root
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = requireArguments()
serverId = requireNotNull(args.getLong(FRAGMENT_WEB_VIEW_EXTRA_SERVER_ID)) { "Server id has not been supplied!" }
instanceUrl = requireNotNull(args.getString(FRAGMENT_WEB_VIEW_EXTRA_URL)) { "Server url has not been supplied!" }
requireActivity().onBackPressedDispatcher.addCallback(this) {
if (!connected || !webappFunctionChannel.goBack()) {
isEnabled = false
activity?.onBackPressed()
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_webViewBinding = FragmentWebviewBinding.inflate(inflater, container, false)
return webView.apply { applyWindowInsetsAsMargins() }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Apply window insets
ViewCompat.requestApplyInsets(webView)
// Setup exclusion rects for gestures
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
webView.doOnNextLayout { webView ->
// Maximum allowed exclusion rect height is 200dp,
// offsetting 100dp from the center in both directions
// uses the maximum available space
val verticalCenter = webView.measuredHeight / 2
val offset = webView.resources.dip(100)
// Arbitrary, currently 2x minimum touch target size
val exclusionWidth = webView.resources.dip(96)
webView.systemGestureExclusionRects = listOf(
Rect(
0,
verticalCenter - offset,
exclusionWidth,
verticalCenter + offset
)
)
}
}
// Setup WebView
webView.initialize()
// Process JS functions called from other components (e.g. the PlayerActivity)
lifecycleScope.launch {
for (function in webappFunctionChannel) {
webView.loadUrl("javascript:$function")
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_webViewBinding = null
}
@SuppressLint("SetJavaScriptEnabled")
private fun WebView.initialize() {
webViewClient = object : WebViewClientCompat() {
override fun shouldInterceptRequest(webView: WebView, request: WebResourceRequest): WebResourceResponse? {
val url = request.url
val path = url.path?.toLowerCase(Locale.ROOT) ?: return null
return when {
path.endsWith(Constants.WEB_CONFIG_PATH) -> {
runOnUiThread {
webView.evaluateJavascript(JS_INJECTION_CODE) {
onConnectedToWebapp()
}
}
null // continue loading normally
}
path.contains("native") -> webView.context.loadAsset("native/${url.lastPathSegment}")
path.endsWith(Constants.CAST_SDK_PATH) -> {
// Load the chrome.cast.js library instead
webView.context.loadAsset("native/chrome.cast.js")
}
path.endsWith(Constants.SESSION_CAPABILITIES_PATH) -> {
lifecycleScope.launch {
val credentials = suspendCoroutine<JSONObject> { continuation ->
webView.evaluateJavascript("window.localStorage.getItem('jellyfin_credentials')") { result ->
try {
continuation.resume(JSONObject(result.unescapeJson()))
} catch (e: JSONException) {
val message = "Failed to extract credentials"
Timber.e(e, message)
continuation.resumeWithException(Exception(message, e))
}
}
}
val server = credentials.getJSONArray("Servers").getJSONObject(0)
val user = server.getString("UserId")
val token = server.getString("AccessToken")
serverController.setupUser(serverId, user, token)
initLocale()
}
null
}
else -> null
}
}
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) {
val errorMessage = errorResponse.data?.run { bufferedReader().use(Reader::readText) }
Timber.e("Received WebView HTTP %d error: %s", errorResponse.statusCode, errorMessage)
if (request.url == Uri.parse(instanceUrl)) onErrorReceived()
}
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceErrorCompat) {
val description = if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_RESOURCE_ERROR_GET_DESCRIPTION)) error.description else null
Timber.e("Received WebView error at %s: %s", request.url.toString(), description)
if (request.url.toString() == instanceUrl) onErrorReceived()
}
}
webChromeClient = WebChromeClient()
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
}
addJavascriptInterface(NativeInterface(this@WebViewFragment), "NativeInterface")
addJavascriptInterface(NativePlayer(this@WebViewFragment), "NativePlayer")
addJavascriptInterface(externalPlayer, "ExternalPlayer")
loadUrl(instanceUrl)
}
fun onConnectedToWebapp() {
connected = true
(activity as? MainActivity)?.requestNoBatteryOptimizations()
}
fun onSelectServer(error: Boolean = false) = runOnUiThread {
val activity = activity
if (activity != null && activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
if (error) {
val extras = Bundle().apply {
putBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR, true)
}
parentFragmentManager.replaceFragment<ConnectFragment>(extras)
} else {
parentFragmentManager.addFragment<ConnectFragment>()
}
}
}
fun onErrorReceived() {
connected = false
onSelectServer(error = true)
}
override fun loadNativePlayer(args: Bundle) = runOnUiThread {
parentFragmentManager.beginTransaction().apply {
add<PlayerFragment>(R.id.fragment_container, args = args)
addToBackStack(null)
}.commit()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == Constants.HANDLE_EXTERNAL_PLAYER) {
externalPlayer.handleActivityResult(resultCode, data)
}
}
}