Create RoomWidget feature

This commit is contained in:
ganfra 2020-05-13 20:04:19 +02:00
parent 91301197ea
commit 9778999a7f
12 changed files with 532 additions and 5 deletions

View file

@ -95,6 +95,7 @@ import im.vector.riotx.features.settings.ignored.VectorSettingsIgnoredUsersFragm
import im.vector.riotx.features.settings.push.PushGatewaysFragment
import im.vector.riotx.features.share.IncomingShareFragment
import im.vector.riotx.features.signout.soft.SoftLogoutFragment
import im.vector.riotx.features.widgets.WidgetFragment
@Module
interface FragmentModule {
@ -468,4 +469,9 @@ interface FragmentModule {
@IntoMap
@FragmentKey(SharedSecuredStorageKeyFragment::class)
fun bindSharedSecuredStorageKeyFragment(fragment: SharedSecuredStorageKeyFragment): Fragment
@Binds
@IntoMap
@FragmentKey(WidgetFragment::class)
fun bindWidgetFragment(fragment: WidgetFragment): Fragment
}

View file

@ -23,21 +23,27 @@ interface WebViewEventListener {
*
* @param url The url about to be rendered.
*/
fun pageWillStart(url: String)
fun pageWillStart(url: String){
//NO-OP
}
/**
* Triggered when a loading webview page has started.
*
* @param url The rendering url.
*/
fun onPageStarted(url: String)
fun onPageStarted(url: String){
//NO-OP
}
/**
* Triggered when a loading webview page has finished loading but has not been rendered yet.
*
* @param url The finished url.
*/
fun onPageFinished(url: String)
fun onPageFinished(url: String){
//NO-OP
}
/**
* Triggered when an error occurred while loading a page.
@ -46,7 +52,9 @@ interface WebViewEventListener {
* @param errorCode The error code.
* @param description The error description.
*/
fun onPageError(url: String, errorCode: Int, description: String)
fun onPageError(url: String, errorCode: Int, description: String){
//NO-OP
}
/**
* Triggered when a webview load an url
@ -54,5 +62,7 @@ interface WebViewEventListener {
* @param url The url about to be rendered.
* @return true if the method needs to manage some custom handling
*/
fun shouldOverrideUrlLoading(url: String): Boolean
fun shouldOverrideUrlLoading(url: String): Boolean{
return false
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* 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
*
* http://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 im.vector.riotx.features.widgets
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class RoomWidgetAction : VectorViewModelAction

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* 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
*
* http://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 im.vector.riotx.features.widgets
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.fragments.roomwidgets.WebviewPermissionUtils
import im.vector.riotx.R
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.features.themes.ThemeUtils
import im.vector.riotx.features.webview.VectorWebViewClient
import im.vector.riotx.features.webview.WebViewEventListener
import im.vector.riotx.features.widgets.webview.clearAfterWidget
import im.vector.riotx.features.widgets.webview.setupForWidget
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_widget.*
import timber.log.Timber
import javax.inject.Inject
@Parcelize
data class WidgetArgs(
val widgetId: String
) : Parcelable
class WidgetFragment @Inject constructor(
private val viewModelFactory: RoomWidgetViewModel.Factory
) : VectorBaseFragment(), RoomWidgetViewModel.Factory by viewModelFactory, WebViewEventListener {
private val fragmentArgs: WidgetArgs by args()
private val viewModel: RoomWidgetViewModel by fragmentViewModel()
override fun getLayoutResId() = R.layout.fragment_widget
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
widgetWebView.setupForWidget(this)
}
override fun onDestroyView() {
super.onDestroyView()
widgetWebView.clearAfterWidget()
}
override fun onResume() {
super.onResume()
widgetWebView?.let {
it.resumeTimers()
it.onResume()
}
}
override fun onPause() {
super.onPause()
widgetWebView?.let {
it.pauseTimers()
it.onPause()
}
}
override fun invalidate() = withState(viewModel) { state ->
Timber.v("Invalidate with state: $state")
}
override fun onPageStarted(url: String) {
}
override fun onPageFinished(url: String) {
}
override fun onPageError(url: String, errorCode: Int, description: String) {
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* 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
*
* http://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 im.vector.riotx.features.widgets
import im.vector.riotx.core.platform.VectorViewEvents
sealed class RoomWidgetViewEvents : VectorViewEvents

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* 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
*
* http://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 im.vector.riotx.features.widgets
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.session.Session
import im.vector.riotx.core.platform.VectorViewModel
class RoomWidgetViewModel @AssistedInject constructor(@Assisted initialState: WidgetViewState,
private val session: Session)
: VectorViewModel<WidgetViewState, RoomWidgetAction, RoomWidgetViewEvents>(initialState) {
@AssistedInject.Factory
interface Factory {
fun create(initialState: WidgetViewState): RoomWidgetViewModel
}
companion object : MvRxViewModelFactory<RoomWidgetViewModel, WidgetViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: WidgetViewState): RoomWidgetViewModel? {
val factory = when (viewModelContext) {
is FragmentViewModelContext -> viewModelContext.fragment as? Factory
is ActivityViewModelContext -> viewModelContext.activity as? Factory
}
return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface")
}
}
override fun handle(action: RoomWidgetAction) {
//TODO
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* 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
*
* http://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 im.vector.riotx.features.widgets
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
enum class WidgetStatus {
UNKNOWN,
WIDGET_NOT_ALLOWED,
WIDGET_ALLOWED
}
data class WidgetViewState(
val widgetId: String,
val status: WidgetStatus = WidgetStatus.UNKNOWN,
val formattedURL: Async<String> = Uninitialized,
val webviewLoadedUrl: Async<String> = Uninitialized,
val widgetName: String = "",
val canManageWidgets: Boolean = false,
val createdByMe: Boolean = false
) : MvRxState {
constructor(widgetArgs: WidgetArgs) : this(widgetId = widgetArgs.widgetId)
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* 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
*
* http://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 im.vector.riotx.features.widgets
import im.vector.matrix.android.api.util.JsonDict
// Example of data:
// {
// "event.data": {
// "action": "get_widgets",
// "room_id": "!byqyNXFYAGirEulaEm:matrix.org",
// "_id": "1526370173321-0.55myregve98-1"
// }
// }
typealias WidgetEventData = Map<String, JsonDict>

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* 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
*
* http://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 im.vector.fragments.roomwidgets
import android.annotation.SuppressLint
import android.content.Context
import android.webkit.PermissionRequest
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import im.vector.riotx.R
object WebviewPermissionUtils {
@SuppressLint("NewApi")
fun promptForPermissions(@StringRes title: Int, request: PermissionRequest, context: Context) {
val allowedPermissions = request.resources.map {
it to false
}.toMutableList()
AlertDialog.Builder(context)
.setTitle(title)
.setMultiChoiceItems(
request.resources.map { webPermissionToHumanReadable(it, context) }.toTypedArray()
, null
) { _, which, isChecked ->
allowedPermissions[which] = allowedPermissions[which].first to isChecked
}
.setPositiveButton(R.string.room_widget_resource_grant_permission) { _, _->
request.grant(allowedPermissions.mapNotNull { perm ->
perm.first.takeIf { perm.second }
}.toTypedArray())
}
.setNegativeButton(R.string.room_widget_resource_decline_permission) { _, _ ->
request.deny()
}
.show()
}
private fun webPermissionToHumanReadable(permission: String, context: Context): String {
return when (permission) {
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> context.getString(R.string.room_widget_webview_access_microphone)
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> context.getString(R.string.room_widget_webview_access_camera)
PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID -> context.getString(R.string.room_widget_webview_read_protected_media)
else -> permission
}
}
}

View file

@ -0,0 +1,104 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* 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
*
* http://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 im.vector.riotx.features.widgets.webview
import android.annotation.SuppressLint
import android.os.Build
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import im.vector.fragments.roomwidgets.WebviewPermissionUtils
import im.vector.riotx.R
import im.vector.riotx.features.themes.ThemeUtils
import im.vector.riotx.features.webview.VectorWebViewClient
import im.vector.riotx.features.webview.WebViewEventListener
@SuppressLint("NewApi")
fun WebView.setupForWidget(webViewEventListener: WebViewEventListener) {
// xml value seems ignored
this.setBackgroundColor(ThemeUtils.getColor(context, R.attr.vctr_bottom_nav_background_color))
// clear caches
this.clearHistory()
this.clearFormData()
this.clearCache(true)
this.settings.let { settings ->
// does not cache the data
settings.cacheMode = WebSettings.LOAD_NO_CACHE
// Enable Javascript
settings.javaScriptEnabled = true
// Use WideViewport and Zoom out if there is no viewport defined
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
// Enable pinch to zoom without the zoom buttons
settings.builtInZoomControls = true
// Allow use of Local Storage
settings.domStorageEnabled = true
settings.allowFileAccessFromFileURLs = true
settings.allowUniversalAccessFromFileURLs = true
settings.displayZoomControls = false
}
// Permission requests
this.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
WebviewPermissionUtils.promptForPermissions(R.string.room_widget_resource_permission_title, request, context)
}
}
this.webViewClient = VectorWebViewClient(webViewEventListener)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptThirdPartyCookies(this, false)
}
}
fun WebView.clearAfterWidget() {
// Make sure you remove the WebView from its parent view before doing anything.
(this.parent as? ViewGroup)?.removeAllViews()
this.webChromeClient = null
this.webViewClient = null
this.clearHistory()
// NOTE: clears RAM cache, if you pass true, it will also clear the disk cache.
this.clearCache(true)
// Loading a blank page is optional, but will ensure that the WebView isn't doing anything when you destroy it.
this.loadUrl("about:blank")
this.onPause()
this.removeAllViews()
// NOTE: This pauses JavaScript execution for ALL WebViews,
// do not use if you have other WebViews still alive.
// If you create another WebView after calling this,
// make sure to call mWebView.resumeTimers().
this.pauseTimers()
// NOTE: This can occasionally cause a segfault below API 17 (4.2)
this.destroy()
}

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<WebView
android:id="@+id/widgetWebView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentBottom="true"
android:background="@android:color/transparent" />
<ProgressBar
android:id="@+id/widgetProgressBar"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="6dp"
android:layout_alignParentTop="true"
android:background="?colorPrimary"
android:indeterminate="true" />
<LinearLayout
android:id="@+id/widgetErrorLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:background="?vctr_bottom_nav_background_color"
android:orientation="horizontal"
android:padding="16dp"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:src="@drawable/error" />
<TextView
android:id="@+id/widgetErrorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:textColor="?android:textColorPrimary"
android:textStyle="bold"
tools:text="Fail to load widget " />
</LinearLayout>
</RelativeLayout>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:icon="@drawable/ic_refresh_cw"
android:iconTint="?attr/vctr_icon_tint_on_dark_action_bar_color"
android:title="@string/room_widget_reload"
app:showAsAction="never" />
<item
android:id="@+id/action_widget_open_ext"
android:iconTint="?attr/vctr_icon_tint_on_dark_action_bar_color"
android:title="@string/room_widget_open_in_browser"
app:showAsAction="never" />
<item
android:id="@+id/action_close"
android:icon="@drawable/ic_close_round"
android:iconTint="@color/vector_error_color"
android:title="@string/delete"
app:showAsAction="never" />
<item
android:id="@+id/action_revoke"
android:title="@string/room_widget_revoke_access"
app:showAsAction="never" />
</menu>