Merge branch 'feature/media_attachment' into develop

This commit is contained in:
ganfra
2019-04-11 13:21:51 +02:00
75 changed files with 2447 additions and 189 deletions

View File

@ -0,0 +1,41 @@
/*
* Copyright 2019 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.riotredesign.core.dialogs
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import im.vector.riotredesign.R
internal abstract class DialogAdapter(context: Context) : ArrayAdapter<DialogListItem>(context, R.layout.item_dialog) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
var view = convertView
if (view == null) {
view = LayoutInflater.from(context).inflate(R.layout.item_dialog, parent, false)
view.tag = DialogListItemHolder(view)
}
(view!!.tag as DialogListItemHolder).let {
it.icon.setImageResource(getItem(position).iconRes)
it.text.setText(getItem(position).titleRes)
}
return view
}
}

View File

@ -0,0 +1,29 @@
/*
* Copyright 2019 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.riotredesign.core.dialogs
import android.content.Context
import im.vector.riotredesign.core.dialogs.DialogAdapter
import im.vector.riotredesign.core.dialogs.DialogListItem
internal class DialogCallAdapter(context: Context) : DialogAdapter(context) {
init {
add(DialogListItem.StartVoiceCall)
add(DialogListItem.StartVideoCall)
}
}

View File

@ -0,0 +1,36 @@
/*
* Copyright 2019 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.riotredesign.core.dialogs
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import im.vector.riotredesign.R
internal sealed class DialogListItem(@DrawableRes val iconRes: Int,
@StringRes val titleRes: Int) {
object StartVoiceCall : DialogListItem(R.drawable.voice_call_green, R.string.action_voice_call)
object StartVideoCall : DialogListItem(R.drawable.video_call_green, R.string.action_video_call)
object SendFile : DialogListItem(R.drawable.ic_material_file, R.string.option_send_files)
object SendVoice : DialogListItem(R.drawable.vector_micro_green, R.string.option_send_voice)
object SendSticker : DialogListItem(R.drawable.ic_send_sticker, R.string.option_send_sticker)
object TakePhoto : DialogListItem(R.drawable.ic_material_camera, R.string.option_take_photo)
object TakeVideo : DialogListItem(R.drawable.ic_material_videocam, R.string.option_take_video)
object TakePhotoVideo : DialogListItem(R.drawable.ic_material_camera, R.string.option_take_photo_video)
}

View File

@ -0,0 +1,37 @@
/*
* Copyright 2019 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.riotredesign.core.dialogs
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import butterknife.BindView
import butterknife.ButterKnife
import im.vector.riotredesign.R
class DialogListItemHolder(view: View) {
@BindView(R.id.adapter_item_dialog_icon)
lateinit var icon: ImageView
@BindView(R.id.adapter_item_dialog_text)
lateinit var text: TextView
init {
ButterKnife.bind(this, view)
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright 2019 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.riotredesign.core.dialogs
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import im.vector.riotredesign.core.platform.Restorable
import timber.log.Timber
private const val KEY_DIALOG_IS_DISPLAYED = "DialogLocker.KEY_DIALOG_IS_DISPLAYED"
/**
* Class to avoid displaying twice the same dialog
*/
class DialogLocker() : Restorable {
private var isDialogDisplayed: Boolean = false
private fun unlock() {
isDialogDisplayed = false
}
private fun lock() {
isDialogDisplayed = true
}
fun displayDialog(builder: () -> AlertDialog.Builder): AlertDialog? {
return if (isDialogDisplayed) {
Timber.w("Filtered dialog request")
null
} else {
builder
.invoke()
.create()
.apply {
setOnShowListener { lock() }
setOnCancelListener { unlock() }
setOnDismissListener { unlock() }
show()
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(KEY_DIALOG_IS_DISPLAYED, isDialogDisplayed)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
isDialogDisplayed = savedInstanceState?.getBoolean(KEY_DIALOG_IS_DISPLAYED, false) == true
}
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2019 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.riotredesign.core.dialogs
import android.content.Context
import im.vector.riotredesign.core.dialogs.DialogAdapter
import im.vector.riotredesign.core.dialogs.DialogListItem
internal class DialogSendItemAdapter(context: Context, items: MutableList<DialogListItem>) : DialogAdapter(context) {
init {
addAll(items)
}
}

View File

@ -0,0 +1,84 @@
/*
* Copyright 2019 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.riotredesign.core.dialogs
import android.app.Activity
import android.text.Editable
import android.text.TextUtils
import android.text.TextWatcher
import android.widget.Button
import androidx.appcompat.app.AlertDialog
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import im.vector.riotredesign.R
class ExportKeysDialog {
fun show(activity: Activity, exportKeyDialogListener: ExportKeyDialogListener) {
val dialogLayout = activity.layoutInflater.inflate(R.layout.dialog_export_e2e_keys, null)
val builder = AlertDialog.Builder(activity)
.setTitle(R.string.encryption_export_room_keys)
.setView(dialogLayout)
val passPhrase1EditText = dialogLayout.findViewById<TextInputEditText>(R.id.dialog_e2e_keys_passphrase_edit_text)
val passPhrase2EditText = dialogLayout.findViewById<TextInputEditText>(R.id.dialog_e2e_keys_confirm_passphrase_edit_text)
val passPhrase2Til = dialogLayout.findViewById<TextInputLayout>(R.id.dialog_e2e_keys_confirm_passphrase_til)
val exportButton = dialogLayout.findViewById<Button>(R.id.dialog_e2e_keys_export_button)
val textWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
}
override fun afterTextChanged(s: Editable) {
when {
TextUtils.isEmpty(passPhrase1EditText.text) -> {
exportButton.isEnabled = false
passPhrase2Til.error = null
}
TextUtils.equals(passPhrase1EditText.text, passPhrase2EditText.text) -> {
exportButton.isEnabled = true
passPhrase2Til.error = null
}
else -> {
exportButton.isEnabled = false
passPhrase2Til.error = activity.getString(R.string.passphrase_passphrase_does_not_match)
}
}
}
}
passPhrase1EditText.addTextChangedListener(textWatcher)
passPhrase2EditText.addTextChangedListener(textWatcher)
val exportDialog = builder.show()
exportButton.setOnClickListener {
exportKeyDialogListener.onPassphrase(passPhrase1EditText.text.toString())
exportDialog.dismiss()
}
}
interface ExportKeyDialogListener {
fun onPassphrase(passphrase: String)
}
}

View File

@ -0,0 +1,240 @@
/*
*
* * Copyright 2019 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.riotredesign.core.utils
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Browser
import android.provider.MediaStore
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import im.vector.riotredesign.BuildConfig
import im.vector.riotredesign.R
import timber.log.Timber
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* Open a url in the internet browser of the system
*/
fun openUrlInExternalBrowser(context: Context, url: String?) {
url?.let {
openUrlInExternalBrowser(context, Uri.parse(it))
}
}
/**
* Open a uri in the internet browser of the system
*/
fun openUrlInExternalBrowser(context: Context, uri: Uri?) {
uri?.let {
val browserIntent = Intent(Intent.ACTION_VIEW, it).apply {
putExtra(Browser.EXTRA_APPLICATION_ID, context.packageName)
}
try {
context.startActivity(browserIntent)
} catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(R.string.error_no_external_application_found)
}
}
}
/**
* Open sound recorder external application
*/
fun openSoundRecorder(activity: Activity, requestCode: Int) {
val recordSoundIntent = Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION)
// Create chooser
val chooserIntent = Intent.createChooser(recordSoundIntent, activity.getString(R.string.go_on_with))
try {
activity.startActivityForResult(chooserIntent, requestCode)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}
/**
* Open file selection activity
*/
fun openFileSelection(activity: Activity,
fragment: Fragment?,
allowMultipleSelection: Boolean,
requestCode: Int) {
val fileIntent = Intent(Intent.ACTION_GET_CONTENT)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
fileIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultipleSelection)
}
fileIntent.addCategory(Intent.CATEGORY_OPENABLE)
fileIntent.type = "*/*"
try {
fragment
?.startActivityForResult(fileIntent, requestCode)
?: run {
activity.startActivityForResult(fileIntent, requestCode)
}
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}
/**
* Open external video recorder
*/
fun openVideoRecorder(activity: Activity, requestCode: Int) {
val captureIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE)
// lowest quality
captureIntent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 0)
try {
activity.startActivityForResult(captureIntent, requestCode)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}
/**
* Open external camera
* @return the latest taken picture camera uri
*/
fun openCamera(activity: Activity, titlePrefix: String, requestCode: Int): String? {
val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
// the following is a fix for buggy 2.x devices
val date = Date()
val formatter = SimpleDateFormat("yyyyMMddHHmmss", Locale.US)
val values = ContentValues()
values.put(MediaStore.Images.Media.TITLE, titlePrefix + formatter.format(date))
// The Galaxy S not only requires the name of the file to output the image to, but will also not
// set the mime type of the picture it just took (!!!). We assume that the Galaxy S takes image/jpegs
// so the attachment uploader doesn't freak out about there being no mimetype in the content database.
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
var dummyUri: Uri? = null
try {
dummyUri = activity.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
if (null == dummyUri) {
Timber.e("Cannot use the external storage media to save image")
}
} catch (uoe: UnsupportedOperationException) {
Timber.e(uoe, "Unable to insert camera URI into MediaStore.Images.Media.EXTERNAL_CONTENT_URI " +
"no SD card? Attempting to insert into device storage.")
} catch (e: Exception) {
Timber.e(e, "Unable to insert camera URI into MediaStore.Images.Media.EXTERNAL_CONTENT_URI. $e")
}
if (null == dummyUri) {
try {
dummyUri = activity.contentResolver.insert(MediaStore.Images.Media.INTERNAL_CONTENT_URI, values)
if (null == dummyUri) {
Timber.e("Cannot use the internal storage to save media to save image")
}
} catch (e: Exception) {
Timber.e(e, "Unable to insert camera URI into internal storage. Giving up. $e")
}
}
if (dummyUri != null) {
captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, dummyUri)
Timber.d("trying to take a photo on " + dummyUri.toString())
} else {
Timber.d("trying to take a photo with no predefined uri")
}
// Store the dummy URI which will be set to a placeholder location. When all is lost on Samsung devices,
// this will point to the data we're looking for.
// Because Activities tend to use a single MediaProvider for all their intents, this field will only be the
// *latest* TAKE_PICTURE Uri. This is deemed acceptable as the normal flow is to create the intent then immediately
// fire it, meaning onActivityResult/getUri will be the next thing called, not another createIntentFor.
val result = if (dummyUri == null) null else dummyUri.toString()
try {
activity.startActivityForResult(captureIntent, requestCode)
return result
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
return null
}
/**
* Send an email to address with optional subject and message
*/
fun sendMailTo(address: String, subject: String? = null, message: String? = null, activity: Activity) {
val intent = Intent(Intent.ACTION_SENDTO, Uri.fromParts(
"mailto", address, null))
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
intent.putExtra(Intent.EXTRA_TEXT, message)
try {
activity.startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}
/**
* Open an arbitrary uri
*/
fun openUri(activity: Activity, uri: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
try {
activity.startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}
/**
* Send media to a third party application.
*
* @param activity the activity
* @param savedMediaPath the media path
* @param mimeType the media mime type.
*/
fun openMedia(activity: Activity, savedMediaPath: String, mimeType: String) {
val file = File(savedMediaPath)
val uri = FileProvider.getUriForFile(activity, BuildConfig.APPLICATION_ID + ".fileProvider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
try {
activity.startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

View File

@ -0,0 +1,397 @@
/*
* Copyright 2019 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.riotredesign.core.utils
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.text.TextUtils
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import im.vector.riotredesign.R
import timber.log.Timber
import java.util.*
private const val LOG_TAG = "PermissionUtils"
// Android M permission request code management
private const val PERMISSIONS_GRANTED = true
private const val PERMISSIONS_DENIED = !PERMISSIONS_GRANTED
// Permission bit
private const val PERMISSION_BYPASSED = 0x0
const val PERMISSION_CAMERA = 0x1
private const val PERMISSION_WRITE_EXTERNAL_STORAGE = 0x1 shl 1
private const val PERMISSION_RECORD_AUDIO = 0x1 shl 2
private const val PERMISSION_READ_CONTACTS = 0x1 shl 3
// Permissions sets
const val PERMISSIONS_FOR_AUDIO_IP_CALL = PERMISSION_RECORD_AUDIO
const val PERMISSIONS_FOR_VIDEO_IP_CALL = PERMISSION_CAMERA or PERMISSION_RECORD_AUDIO
const val PERMISSIONS_FOR_TAKING_PHOTO = PERMISSION_CAMERA or PERMISSION_WRITE_EXTERNAL_STORAGE
const val PERMISSIONS_FOR_MEMBERS_SEARCH = PERMISSION_READ_CONTACTS
const val PERMISSIONS_FOR_MEMBER_DETAILS = PERMISSION_READ_CONTACTS
const val PERMISSIONS_FOR_ROOM_AVATAR = PERMISSION_CAMERA
const val PERMISSIONS_FOR_VIDEO_RECORDING = PERMISSION_CAMERA or PERMISSION_RECORD_AUDIO
const val PERMISSIONS_FOR_WRITING_FILES = PERMISSION_WRITE_EXTERNAL_STORAGE
private const val PERMISSIONS_EMPTY = PERMISSION_BYPASSED
// Request code to ask permission to the system (arbitrary values)
const val PERMISSION_REQUEST_CODE = 567
const val PERMISSION_REQUEST_CODE_LAUNCH_CAMERA = 568
const val PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA = 569
const val PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA = 570
const val PERMISSION_REQUEST_CODE_AUDIO_CALL = 571
const val PERMISSION_REQUEST_CODE_VIDEO_CALL = 572
const val PERMISSION_REQUEST_CODE_EXPORT_KEYS = 573
const val PERMISSION_REQUEST_CODE_CHANGE_AVATAR = 574
/**
* Log the used permissions statuses.
*/
fun logPermissionStatuses(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val permissions = Arrays.asList(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_CONTACTS)
Timber.d("## logPermissionStatuses() : log the permissions status used by the app")
for (permission in permissions) {
Timber.d(("Status of [$permission] : " +
if (PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(context, permission))
"PERMISSION_GRANTED"
else
"PERMISSION_DENIED"))
}
}
}
/**
* See [.checkPermissions]
*
* @param permissionsToBeGrantedBitMap
* @param activity
* @return true if the permissions are granted (synchronous flow), false otherwise (asynchronous flow)
*/
fun checkPermissions(permissionsToBeGrantedBitMap: Int,
activity: Activity,
requestCode: Int = PERMISSION_REQUEST_CODE): Boolean {
return checkPermissions(permissionsToBeGrantedBitMap, activity, null, requestCode)
}
/**
* See [.checkPermissions]
*
* @param permissionsToBeGrantedBitMap
* @param fragment
* @return true if the permissions are granted (synchronous flow), false otherwise (asynchronous flow)
*/
fun checkPermissions(permissionsToBeGrantedBitMap: Int,
fragment: Fragment,
requestCode: Int = PERMISSION_REQUEST_CODE): Boolean {
return checkPermissions(permissionsToBeGrantedBitMap, fragment.activity, fragment, requestCode)
}
/**
* Check if the permissions provided in the list are granted.
* This is an asynchronous method if permissions are requested, the final response
* is provided in onRequestPermissionsResult(). In this case checkPermissions()
* returns false.
* <br></br>If checkPermissions() returns true, the permissions were already granted.
* The permissions to be granted are given as bit map in permissionsToBeGrantedBitMap (ex: [.PERMISSIONS_FOR_TAKING_PHOTO]).
* <br></br>permissionsToBeGrantedBitMap is passed as the request code in onRequestPermissionsResult().
*
*
* If a permission was already denied by the user, a popup is displayed to
* explain why vector needs the corresponding permission.
*
* @param permissionsToBeGrantedBitMap the permissions bit map to be granted
* @param activity the calling Activity that is requesting the permissions (or fragment parent)
* @param fragment the calling fragment that is requesting the permissions
* @return true if the permissions are granted (synchronous flow), false otherwise (asynchronous flow)
*/
private fun checkPermissions(permissionsToBeGrantedBitMap: Int,
activity: Activity?,
fragment: Fragment?,
requestCode: Int): Boolean {
var isPermissionGranted = false
// sanity check
if (null == activity) {
Timber.w("## checkPermissions(): invalid input data")
isPermissionGranted = false
} else if (PERMISSIONS_EMPTY == permissionsToBeGrantedBitMap) {
isPermissionGranted = true
} else if (PERMISSIONS_FOR_AUDIO_IP_CALL != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_VIDEO_IP_CALL != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_TAKING_PHOTO != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_MEMBERS_SEARCH != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_MEMBER_DETAILS != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_ROOM_AVATAR != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_VIDEO_RECORDING != permissionsToBeGrantedBitMap
&& PERMISSIONS_FOR_WRITING_FILES != permissionsToBeGrantedBitMap) {
Timber.w("## checkPermissions(): permissions to be granted are not supported")
isPermissionGranted = false
} else {
val permissionListAlreadyDenied = ArrayList<String>()
val permissionsListToBeGranted = ArrayList<String>()
var isRequestPermissionRequired = false
var explanationMessage = ""
// retrieve the permissions to be granted according to the request code bit map
if (PERMISSION_CAMERA == permissionsToBeGrantedBitMap and PERMISSION_CAMERA) {
val permissionType = Manifest.permission.CAMERA
isRequestPermissionRequired = isRequestPermissionRequired or
updatePermissionsToBeGranted(activity, permissionListAlreadyDenied, permissionsListToBeGranted, permissionType)
}
if (PERMISSION_RECORD_AUDIO == permissionsToBeGrantedBitMap and PERMISSION_RECORD_AUDIO) {
val permissionType = Manifest.permission.RECORD_AUDIO
isRequestPermissionRequired = isRequestPermissionRequired or
updatePermissionsToBeGranted(activity, permissionListAlreadyDenied, permissionsListToBeGranted, permissionType)
}
if (PERMISSION_WRITE_EXTERNAL_STORAGE == permissionsToBeGrantedBitMap and PERMISSION_WRITE_EXTERNAL_STORAGE) {
val permissionType = Manifest.permission.WRITE_EXTERNAL_STORAGE
isRequestPermissionRequired = isRequestPermissionRequired or
updatePermissionsToBeGranted(activity, permissionListAlreadyDenied, permissionsListToBeGranted, permissionType)
}
// the contact book access is requested for any android platforms
// for android M, we use the system preferences
// for android < M, we use a dedicated settings
if (PERMISSION_READ_CONTACTS == permissionsToBeGrantedBitMap and PERMISSION_READ_CONTACTS) {
val permissionType = Manifest.permission.READ_CONTACTS
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
isRequestPermissionRequired = isRequestPermissionRequired or
updatePermissionsToBeGranted(activity, permissionListAlreadyDenied, permissionsListToBeGranted, permissionType)
} else {
// TODO uncomment
/*if (!ContactsManager.getInstance().isContactBookAccessRequested) {
isRequestPermissionRequired = true
permissionsListToBeGranted.add(permissionType)
}*/
}
}
// if some permissions were already denied: display a dialog to the user before asking again.
if (!permissionListAlreadyDenied.isEmpty()) {
if (permissionsToBeGrantedBitMap == PERMISSIONS_FOR_VIDEO_IP_CALL || permissionsToBeGrantedBitMap == PERMISSIONS_FOR_AUDIO_IP_CALL) {
// Permission request for VOIP call
if (permissionListAlreadyDenied.contains(Manifest.permission.CAMERA)
&& permissionListAlreadyDenied.contains(Manifest.permission.RECORD_AUDIO)) {
// Both missing
explanationMessage += activity.getString(R.string.permissions_rationale_msg_camera_and_audio)
} else if (permissionListAlreadyDenied.contains(Manifest.permission.RECORD_AUDIO)) {
// Audio missing
explanationMessage += activity.getString(R.string.permissions_rationale_msg_record_audio)
explanationMessage += activity.getString(R.string.permissions_rationale_msg_record_audio_explanation)
} else if (permissionListAlreadyDenied.contains(Manifest.permission.CAMERA)) {
// Camera missing
explanationMessage += activity.getString(R.string.permissions_rationale_msg_camera)
explanationMessage += activity.getString(R.string.permissions_rationale_msg_camera_explanation)
}
} else {
permissionListAlreadyDenied.forEach {
when (it) {
Manifest.permission.CAMERA -> {
if (!TextUtils.isEmpty(explanationMessage)) {
explanationMessage += "\n\n"
}
explanationMessage += activity.getString(R.string.permissions_rationale_msg_camera)
}
Manifest.permission.RECORD_AUDIO -> {
if (!TextUtils.isEmpty(explanationMessage)) {
explanationMessage += "\n\n"
}
explanationMessage += activity.getString(R.string.permissions_rationale_msg_record_audio)
}
Manifest.permission.WRITE_EXTERNAL_STORAGE -> {
if (!TextUtils.isEmpty(explanationMessage)) {
explanationMessage += "\n\n"
}
explanationMessage += activity.getString(R.string.permissions_rationale_msg_storage)
}
Manifest.permission.READ_CONTACTS -> {
if (!TextUtils.isEmpty(explanationMessage)) {
explanationMessage += "\n\n"
}
explanationMessage += activity.getString(R.string.permissions_rationale_msg_contacts)
}
else -> Timber.d("## checkPermissions(): already denied permission not supported")
}
}
}
// display the dialog with the info text
AlertDialog.Builder(activity)
.setTitle(R.string.permissions_rationale_popup_title)
.setMessage(explanationMessage)
.setOnCancelListener { Toast.makeText(activity, R.string.missing_permissions_warning, Toast.LENGTH_SHORT).show() }
.setPositiveButton(R.string.ok) { _, _ ->
if (!permissionsListToBeGranted.isEmpty()) {
fragment?.requestPermissions(permissionsListToBeGranted.toTypedArray(), requestCode)
?: run {
ActivityCompat.requestPermissions(activity, permissionsListToBeGranted.toTypedArray(), requestCode)
}
}
}
.show()
} else {
// some permissions are not granted, ask permissions
if (isRequestPermissionRequired) {
val permissionsArrayToBeGranted = permissionsListToBeGranted.toTypedArray()
// for android < M, we use a custom dialog to request the contacts book access.
/*
if (permissionsListToBeGranted.contains(Manifest.permission.READ_CONTACTS)
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
AlertDialog.Builder(activity)
.setIcon(android.R.drawable.ic_dialog_info)
.setTitle(R.string.permissions_rationale_popup_title)
.setMessage(R.string.permissions_msg_contacts_warning_other_androids)
// gives the contacts book access
.setPositiveButton(R.string.yes) { _, _ ->
ContactsManager.getInstance().setIsContactBookAccessAllowed(true)
fragment?.requestPermissions(permissionsArrayToBeGranted, requestCode)
?: run {
ActivityCompat.requestPermissions(activity, permissionsArrayToBeGranted, requestCode)
}
}
// or reject it
.setNegativeButton(R.string.no) { _, _ ->
ContactsManager.getInstance().setIsContactBookAccessAllowed(false)
fragment?.requestPermissions(permissionsArrayToBeGranted, requestCode)
?: run {
ActivityCompat.requestPermissions(activity, permissionsArrayToBeGranted, requestCode)
}
}
.show()
} else {
fragment?.requestPermissions(permissionsArrayToBeGranted, requestCode)
?: run {
ActivityCompat.requestPermissions(activity, permissionsArrayToBeGranted, requestCode)
}
}
*/
} else {
// permissions were granted, start now.
isPermissionGranted = true
}
}
}
return isPermissionGranted
}
/**
* Helper method used in [.checkPermissions] to populate the list of the
* permissions to be granted (permissionsListToBeGranted_out) and the list of the permissions already denied (permissionAlreadyDeniedList_out).
*
* @param activity calling activity
* @param permissionAlreadyDeniedList_out list to be updated with the permissions already denied by the user
* @param permissionsListToBeGranted_out list to be updated with the permissions to be granted
* @param permissionType the permission to be checked
* @return true if the permission requires to be granted, false otherwise
*/
private fun updatePermissionsToBeGranted(activity: Activity,
permissionAlreadyDeniedList_out: MutableList<String>,
permissionsListToBeGranted_out: MutableList<String>,
permissionType: String): Boolean {
var isRequestPermissionRequested = false
// add permission to be granted
permissionsListToBeGranted_out.add(permissionType)
if (PackageManager.PERMISSION_GRANTED != ContextCompat.checkSelfPermission(activity.applicationContext, permissionType)) {
isRequestPermissionRequested = true
// add permission to the ones that were already asked to the user
if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permissionType)) {
permissionAlreadyDeniedList_out.add(permissionType)
}
}
return isRequestPermissionRequested
}
/**
* Helper method to process [.PERMISSIONS_FOR_AUDIO_IP_CALL]
* on onRequestPermissionsResult() methods.
*
* @param context App context
* @param grantResults permissions granted results
* @return true if audio IP call is permitted, false otherwise
*/
fun onPermissionResultAudioIpCall(context: Context, grantResults: IntArray): Boolean {
val arePermissionsGranted = allGranted(grantResults)
if (!arePermissionsGranted) {
Toast.makeText(context, R.string.permissions_action_not_performed_missing_permissions, Toast.LENGTH_SHORT).show()
}
return arePermissionsGranted
}
/**
* Helper method to process [.PERMISSIONS_FOR_VIDEO_IP_CALL]
* on onRequestPermissionsResult() methods.
* For video IP calls, record audio and camera permissions are both mandatory.
*
* @param context App context
* @param grantResults permissions granted results
* @return true if video IP call is permitted, false otherwise
*/
fun onPermissionResultVideoIpCall(context: Context, grantResults: IntArray): Boolean {
val arePermissionsGranted = allGranted(grantResults)
if (!arePermissionsGranted) {
Toast.makeText(context, R.string.permissions_action_not_performed_missing_permissions, Toast.LENGTH_SHORT).show()
}
return arePermissionsGranted
}
/**
* Return true if all permissions are granted, false if not or if permission request has been cancelled
*/
fun allGranted(grantResults: IntArray): Boolean {
if (grantResults.isEmpty()) {
// A cancellation occurred
return false
}
var granted = true
grantResults.forEach {
granted = granted && PackageManager.PERMISSION_GRANTED == it
}
return granted
}

View File

@ -16,12 +16,14 @@
package im.vector.riotredesign.features.home.room.detail
import com.jaiselrahman.filepicker.model.MediaFile
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
sealed class RoomDetailActions {
data class SendMessage(val text: String) : RoomDetailActions()
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
object IsDisplayed : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()

View File

@ -16,6 +16,8 @@
package im.vector.riotredesign.features.home.room.detail
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
@ -28,6 +30,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.fragmentViewModel
import com.jaiselrahman.filepicker.activity.FilePickerActivity
import com.jaiselrahman.filepicker.config.Configurations
import com.jaiselrahman.filepicker.model.MediaFile
import com.otaliastudios.autocomplete.Autocomplete
import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy
@ -35,11 +40,18 @@ import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.R
import im.vector.riotredesign.core.dialogs.DialogListItem
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.core.platform.VectorBaseFragment
import im.vector.riotredesign.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA
import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA
import im.vector.riotredesign.core.utils.checkPermissions
import im.vector.riotredesign.core.utils.openCamera
import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandPresenter
import im.vector.riotredesign.features.autocomplete.command.CommandAutocompletePolicy
import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserPresenter
@ -61,6 +73,7 @@ import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope
import org.koin.core.parameter.parametersOf
import timber.log.Timber
@Parcelize
@ -70,6 +83,10 @@ data class RoomDetailArgs(
) : Parcelable
private const val CAMERA_VALUE_TITLE = "attachment"
private const val REQUEST_FILES_REQUEST_CODE = 0
private const val TAKE_IMAGE_REQUEST_CODE = 1
class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callback, AutocompleteUserPresenter.Callback {
companion object {
@ -103,17 +120,28 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
setupRecyclerView()
setupToolbar()
setupComposer()
setupAttachmentButton()
roomDetailViewModel.subscribe { renderState(it) }
textComposerViewModel.subscribe { renderTextComposerState(it) }
roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) }
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK && data != null) {
when (requestCode) {
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
}
}
}
override fun onResume() {
super.onResume()
roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
}
// PRIVATE METHODS *****************************************************************************
// PRIVATE METHODS *****************************************************************************
private fun setupToolbar() {
val parentActivity = vectorBaseActivity
@ -215,6 +243,77 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
}
}
private fun setupAttachmentButton() {
attachmentButton.setOnClickListener {
val intent = Intent(requireContext(), FilePickerActivity::class.java)
intent.putExtra(FilePickerActivity.CONFIGS, Configurations.Builder()
.setCheckPermission(true)
.setShowFiles(true)
.setShowAudios(true)
.setSkipZeroSizeFiles(true)
.build())
startActivityForResult(intent, REQUEST_FILES_REQUEST_CODE)
/*
val items = ArrayList<DialogListItem>()
// Send file
items.add(DialogListItem.SendFile)
// Send voice
if (PreferencesManager.isSendVoiceFeatureEnabled(this)) {
items.add(DialogListItem.SendVoice.INSTANCE)
}
// Send sticker
//items.add(DialogListItem.SendSticker)
// Camera
//if (PreferencesManager.useNativeCamera(this)) {
items.add(DialogListItem.TakePhoto)
items.add(DialogListItem.TakeVideo)
//} else {
// items.add(DialogListItem.TakePhotoVideo.INSTANCE)
// }
val adapter = DialogSendItemAdapter(requireContext(), items)
AlertDialog.Builder(requireContext())
.setAdapter(adapter) { _, position ->
onSendChoiceClicked(items[position])
}
.setNegativeButton(R.string.cancel, null)
.show()
*/
}
}
private fun onSendChoiceClicked(dialogListItem: DialogListItem) {
Timber.v("On send choice clicked: $dialogListItem")
when (dialogListItem) {
is DialogListItem.SendFile -> {
// launchFileIntent
}
is DialogListItem.SendVoice -> {
//launchAudioRecorderIntent()
}
is DialogListItem.SendSticker -> {
//startStickerPickerActivity()
}
is DialogListItem.TakePhotoVideo -> if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
// launchCamera()
}
is DialogListItem.TakePhoto -> if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA)) {
openCamera(requireActivity(), CAMERA_VALUE_TITLE, TAKE_IMAGE_REQUEST_CODE)
}
is DialogListItem.TakeVideo -> if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA)) {
// launchNativeVideoRecorder()
}
}
}
private fun handleMediaIntent(data: Intent) {
val files: ArrayList<MediaFile> = data.getParcelableArrayListExtra(FilePickerActivity.MEDIA_FILES)
roomDetailViewModel.process(RoomDetailActions.SendMedia(files))
}
private fun renderState(state: RoomDetailViewState) {
renderRoomSummary(state)
timelineEventController.setTimeline(state.timeline)
@ -240,20 +339,20 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
when (sendMessageResult) {
is SendMessageResult.MessageSent,
is SendMessageResult.SlashCommandHandled -> {
is SendMessageResult.SlashCommandHandled -> {
// Clear composer
composerEditText.text = null
}
is SendMessageResult.SlashCommandError -> {
is SendMessageResult.SlashCommandError -> {
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
}
is SendMessageResult.SlashCommandUnknown -> {
is SendMessageResult.SlashCommandUnknown -> {
displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command))
}
is SendMessageResult.SlashCommandResultOk -> {
is SendMessageResult.SlashCommandResultOk -> {
// Ignore
}
is SendMessageResult.SlashCommandResultError -> {
is SendMessageResult.SlashCommandResultError -> {
displayCommandError(sendMessageResult.throwable.localizedMessage)
}
is SendMessageResult.SlashCommandNotImplemented -> {
@ -270,7 +369,7 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
.show()
}
// TimelineEventController.Callback ************************************************************
// TimelineEventController.Callback ************************************************************
override fun onUrlClicked(url: String) {
homePermalinkHandler.launch(url)
@ -285,7 +384,7 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
startActivity(intent)
}
// AutocompleteUserPresenter.Callback
// AutocompleteUserPresenter.Callback
override fun onQueryUsers(query: CharSequence?) {
textComposerViewModel.process(TextComposerActions.QueryUsers(query))

View File

@ -23,7 +23,7 @@ import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.VectorViewModel
@ -69,10 +69,11 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
fun process(action: RoomDetailActions) {
when (action) {
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.SendMedia -> handleSendMedia(action)
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action)
}
}
@ -87,63 +88,63 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
val slashCommandResult = CommandParser.parseSplashCommand(action.text)
when (slashCommandResult) {
is ParsedCommand.ErrorNotACommand -> {
is ParsedCommand.ErrorNotACommand -> {
// Send the text message to the room
room.sendTextMessage(action.text, callback = object : MatrixCallback<Event> {})
room.sendTextMessage(action.text)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
is ParsedCommand.ErrorSyntax -> {
is ParsedCommand.ErrorSyntax -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command)))
}
is ParsedCommand.ErrorEmptySlashCommand -> {
is ParsedCommand.ErrorEmptySlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown("/")))
}
is ParsedCommand.ErrorUnknownSlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown(slashCommandResult.slashCommand)))
}
is ParsedCommand.Invite -> {
is ParsedCommand.Invite -> {
handleInviteSlashCommand(slashCommandResult)
}
is ParsedCommand.SetUserPowerLevel -> {
is ParsedCommand.SetUserPowerLevel -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.ClearScalarToken -> {
is ParsedCommand.ClearScalarToken -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.SetMarkdown -> {
is ParsedCommand.SetMarkdown -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.UnbanUser -> {
is ParsedCommand.UnbanUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.BanUser -> {
is ParsedCommand.BanUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.KickUser -> {
is ParsedCommand.KickUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.JoinRoom -> {
is ParsedCommand.JoinRoom -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.PartRoom -> {
is ParsedCommand.PartRoom -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.SendEmote -> {
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, callback = object : MatrixCallback<Event> {})
is ParsedCommand.SendEmote -> {
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
}
is ParsedCommand.ChangeTopic -> {
is ParsedCommand.ChangeTopic -> {
handleChangeTopicSlashCommand(slashCommandResult)
}
is ParsedCommand.ChangeDisplayName -> {
is ParsedCommand.ChangeDisplayName -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
@ -178,6 +179,23 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
})
}
private fun handleSendMedia(action: RoomDetailActions.SendMedia) {
val attachments = action.mediaFiles.map {
ContentAttachmentData(
size = it.size,
duration = it.duration,
date = it.date,
height = it.height,
width = it.width,
name = it.name,
path = it.path,
mimeType = it.mimeType,
type = ContentAttachmentData.Type.values()[it.mediaType]
)
}
room.sendMedias(attachments)
}
private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) {
displayedEventsObservable.accept(action)
}
@ -216,4 +234,5 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
timeline.dispose()
super.onCleared()
}
}

View File

@ -22,7 +22,12 @@ import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageNoticeContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
@ -32,7 +37,13 @@ import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.*
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.riotredesign.features.html.EventHtmlRenderer
import im.vector.riotredesign.features.media.MediaContentRenderer
import me.gujun.android.span.span
@ -47,6 +58,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
callback: TimelineEventController.Callback?
): VectorEpoxyModel<*>? {
val eventId = event.root.eventId ?: return null
val roomMember = event.roomMember
val nextRoomMember = nextEvent?.roomMember
@ -54,12 +66,12 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60))
?: false
?: false
val showInformation = addDaySeparator
|| nextRoomMember != roomMember
|| nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo
|| nextRoomMember != roomMember
|| nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo
val messageContent: MessageContent = event.root.content.toModel() ?: return null
val time = timelineDateFormatter.formatMessageHour(date)
@ -68,11 +80,12 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val informationData = MessageInformationData(time, avatarUrl, memberName, showInformation)
return when (messageContent) {
is MessageTextContent -> buildTextMessageItem(messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageTextContent -> buildTextMessageItem(event.sendState, messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(eventId, messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, callback)
else -> buildNotHandledMessageItem(messageContent)
else -> buildNotHandledMessageItem(messageContent)
}
}
@ -81,7 +94,8 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return DefaultItem_().text(text)
}
private fun buildImageMessageItem(messageContent: MessageImageContent,
private fun buildImageMessageItem(eventId: String,
messageContent: MessageImageContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageImageItem? {
@ -97,22 +111,30 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
orientation = messageContent.info?.orientation
)
return MessageImageItem_()
.eventId(eventId)
.informationData(informationData)
.mediaData(data)
.clickListener { view -> callback?.onMediaClicked(data, view) }
}
private fun buildTextMessageItem(messageContent: MessageTextContent,
private fun buildTextMessageItem(sendState: SendState,
messageContent: MessageTextContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? {
val bodyToUse = messageContent.formattedBody
?.let {
htmlRenderer.render(it)
}
?: messageContent.body
val bodyToUse = messageContent.formattedBody?.let {
htmlRenderer.render(it)
} ?: messageContent.body
val linkifiedBody = linkifyBody(bodyToUse, callback)
val textColor = if (sendState.isSent()) {
R.color.dark_grey
} else {
R.color.brown_grey
}
val formattedBody = span(bodyToUse) {
this.textColor = colorProvider.getColor(textColor)
}
val linkifiedBody = linkifyBody(formattedBody, callback)
return MessageTextItem_()
.message(linkifiedBody)
.informationData(informationData)

View File

@ -0,0 +1,104 @@
/*
* Copyright 2019 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.riotredesign.features.home.room.detail.timeline.helper
import android.content.Context
import android.text.format.Formatter
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
import im.vector.riotredesign.R
import im.vector.riotredesign.features.media.MediaContentRenderer
import java.io.File
object ContentUploadStateTrackerBinder {
private val updateListeners = mutableMapOf<String, ContentUploadStateTracker.UpdateListener>()
fun bind(eventId: String,
mediaData: MediaContentRenderer.Data,
progressLayout: ViewGroup) {
Matrix.getInstance().currentSession?.also { session ->
val uploadStateTracker = session.contentUploadProgressTracker()
val updateListener = ContentMediaProgressUpdater(progressLayout, mediaData)
updateListeners[eventId] = updateListener
uploadStateTracker.track(eventId, updateListener)
}
}
fun unbind(eventId: String) {
Matrix.getInstance().currentSession?.also { session ->
val uploadStateTracker = session.contentUploadProgressTracker()
updateListeners[eventId]?.also {
uploadStateTracker.untrack(eventId, it)
}
}
}
}
private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
private val mediaData: MediaContentRenderer.Data) : ContentUploadStateTracker.UpdateListener {
override fun onUpdate(state: ContentUploadStateTracker.State) {
when (state) {
is ContentUploadStateTracker.State.Idle -> handleIdle(state)
is ContentUploadStateTracker.State.Failure -> handleFailure(state)
is ContentUploadStateTracker.State.Success -> handleSuccess(state)
is ContentUploadStateTracker.State.ProgressData -> handleProgress(state)
}
}
private fun handleIdle(state: ContentUploadStateTracker.State.Idle) {
if (mediaData.isLocalFile()) {
val file = File(mediaData.url)
progressLayout.visibility = View.VISIBLE
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.progress = 0
progressTextView?.text = formatStats(progressLayout.context, 0L, file.length())
} else {
progressLayout.visibility = View.GONE
}
}
private fun handleFailure(state: ContentUploadStateTracker.State.Failure) {
}
private fun handleSuccess(state: ContentUploadStateTracker.State.Success) {
}
private fun handleProgress(state: ContentUploadStateTracker.State.ProgressData) {
progressLayout.visibility = View.VISIBLE
val percent = 100L * (state.current.toFloat() / state.total.toFloat())
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.progress = percent.toInt()
progressTextView?.text = formatStats(progressLayout.context, state.current, state.total)
}
private fun formatStats(context: Context, current: Long, total: Long): String {
return "${Formatter.formatShortFileSize(context, current)} / ${Formatter.formatShortFileSize(context, total)}"
}
}

View File

@ -28,6 +28,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : VectorEpoxyModel<H>()
abstract val informationData: MessageInformationData
override fun bind(holder: H) {
super.bind(holder)
if (informationData.showInformation) {
holder.avatarImageView.visibility = View.VISIBLE
holder.memberNameView.visibility = View.VISIBLE

View File

@ -17,30 +17,42 @@
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.riotredesign.features.media.MediaContentRenderer
@EpoxyModelClass(layout = R.layout.item_timeline_event_image_message)
abstract class MessageImageItem : AbsMessageItem<MessageImageItem.Holder>() {
@EpoxyAttribute lateinit var mediaData: MediaContentRenderer.Data
@EpoxyAttribute lateinit var eventId: String
@EpoxyAttribute override lateinit var informationData: MessageInformationData
@EpoxyAttribute var clickListener: View.OnClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
MediaContentRenderer.render(mediaData, MediaContentRenderer.Mode.THUMBNAIL, holder.imageView)
ContentUploadStateTrackerBinder.bind(eventId, mediaData, holder.progressLayout)
holder.imageView.setOnClickListener(clickListener)
holder.imageView.isEnabled = !mediaData.isLocalFile()
holder.imageView.alpha = if (mediaData.isLocalFile()) 0.5f else 1f
}
override fun unbind(holder: Holder) {
ContentUploadStateTrackerBinder.unbind(eventId)
super.unbind(holder)
}
class Holder : AbsMessageItem.Holder() {
override val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
override val memberNameView by bind<TextView>(R.id.messageMemberNameView)
override val timeView by bind<TextView>(R.id.messageTimeView)
val progressLayout by bind<ViewGroup>(R.id.messageImageUploadProgressLayout)
val imageView by bind<ImageView>(R.id.messageImageView)
}

View File

@ -25,6 +25,7 @@ import im.vector.matrix.android.api.Matrix
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.riotredesign.core.glide.GlideApp
import kotlinx.android.parcel.Parcelize
import java.io.File
object MediaContentRenderer {
@ -38,7 +39,12 @@ object MediaContentRenderer {
val maxWidth: Int,
val orientation: Int?,
val rotation: Int?
) : Parcelable
) : Parcelable {
fun isLocalFile(): Boolean {
return url != null && File(url).exists()
}
}
enum class Mode {
FULL_SIZE,
@ -51,9 +57,11 @@ object MediaContentRenderer {
imageView.layoutParams.width = width
val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver()
val resolvedUrl = when (mode) {
Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
} ?: return
Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
}
//Fallback to base url
?: data.url
GlideApp
.with(imageView)
@ -65,12 +73,16 @@ object MediaContentRenderer {
fun render(data: Data, imageView: BigImageView) {
val (width, height) = processSize(data, Mode.THUMBNAIL)
val contentUrlResolver = Matrix.getInstance().currentSession!!.contentUrlResolver()
val fullSize = contentUrlResolver.resolveFullSize(data.url)
val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
imageView.showImage(
Uri.parse(thumbnail),
Uri.parse(fullSize)
)
if (data.isLocalFile()) {
imageView.showImage(Uri.parse(data.url))
} else {
val fullSize = contentUrlResolver.resolveFullSize(data.url)
val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
imageView.showImage(
Uri.parse(thumbnail),
Uri.parse(fullSize)
)
}
}
private fun processSize(data: Data, mode: Mode): Pair<Int, Int> {

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/layout_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="?dialogPreferredPadding"
android:paddingLeft="?dialogPreferredPadding"
android:paddingTop="12dp"
android:paddingEnd="?dialogPreferredPadding"
android:paddingRight="?dialogPreferredPadding"
android:paddingBottom="12dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/encryption_export_notice"
android:textSize="16sp" />
<android.support.design.widget.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColorHint="?attr/vctr_default_text_hint_color">
<android.support.design.widget.TextInputEditText
android:id="@+id/dialog_e2e_keys_passphrase_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/passphrase_create_passphrase"
android:inputType="textPassword"
android:textColor="?android:textColorPrimary" />
</android.support.design.widget.TextInputLayout>
<android.support.design.widget.TextInputLayout
android:id="@+id/dialog_e2e_keys_confirm_passphrase_til"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textColorHint="?attr/vctr_default_text_hint_color"
app:errorEnabled="true">
<android.support.design.widget.TextInputEditText
android:id="@+id/dialog_e2e_keys_confirm_passphrase_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/passphrase_confirm_passphrase"
android:inputType="textPassword"
android:textColor="?android:textColorPrimary" />
</android.support.design.widget.TextInputLayout>
<Button
android:id="@+id/dialog_e2e_keys_export_button"
style="@style/VectorButtonStyle"
android:layout_width="match_parent"
android:enabled="false"
android:text="@string/encryption_export_export" />
</LinearLayout>

View File

@ -97,6 +97,17 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<ImageButton
android:id="@+id/attachmentButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/sendButton"
android:layout_toLeftOf="@id/sendButton"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_attach_file_white"
android:tint="?attr/colorAccent" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="48dp"
@ -115,8 +126,8 @@
android:layout_alignParentStart="true"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/sendButton"
android:layout_toLeftOf="@id/sendButton"
android:layout_toStartOf="@id/attachmentButton"
android:layout_toLeftOf="@id/attachmentButton"
android:background="@android:color/transparent"
android:gravity="center_vertical"
android:hint="@string/room_message_placeholder_not_encrypted"

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2019 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.
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:paddingLeft="8dp"
android:paddingRight="8dp">
<!-- Do not use drawableStart for icon size and for RTL -->
<ImageView
android:id="@+id/adapter_item_dialog_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:rotationY="@integer/rtl_mirror_flip"
android:tint="?attr/colorAccent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/video_call_green" />
<TextView
android:id="@+id/adapter_item_dialog_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/adapter_item_dialog_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="@string/action_video_call" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -59,10 +59,27 @@
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageMemberNameView" />
<include
android:id="@+id/messageImageUploadProgressLayout"
layout="@layout/media_upload_download_progress_layout"
android:layout_width="0dp"
android:layout_height="46dp"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageImageView"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/mediaProgressTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textSize="12sp"
tools:text="Information" />
<ProgressBar
android:id="@+id/mediaProgressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_gravity="center_vertical"
android:max="100"
android:min="0"
android:progress="0"
tools:progress="45" />
</LinearLayout>

View File

@ -114,7 +114,8 @@
<item name="vctr_tabbar_background">@drawable/vector_tabbar_background_light</item>
<item name="vctr_pill_background_user_id">@drawable/pill_background_user_id_light</item>
<item name="vctr_pill_background_room_alias">@drawable/pill_background_room_alias_light</item>
<item name="vctr_pill_background_room_alias">@drawable/pill_background_room_alias_light
</item>
<item name="vctr_pill_text_color_user_id">@color/riot_primary_text_color_light</item>
<item name="vctr_pill_text_color_room_alias">@android:color/white</item>
@ -261,6 +262,11 @@
<item name="actionBarTabStyle">@style/Vector.TabView.Group</item>
</style>
<style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat">
<item name="titleTextColor">?attr/actionMenuTextColor</item>
<item name="android:background">?attr/colorPrimary</item>
</style>
<style name="AppTheme.Dialog.Light" parent="Theme.AppCompat.Light.Dialog.Alert" />
</resources>