Merge branch 'feature/media_attachment' into develop
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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()
|
||||
|
@ -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))
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
||||
|
@ -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)}"
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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> {
|
||||
|
BIN
vector/src/main/res/drawable-hdpi/ic_attach_file_white.png
Normal file
After Width: | Height: | Size: 394 B |
BIN
vector/src/main/res/drawable-mdpi/ic_attach_file_white.png
Normal file
After Width: | Height: | Size: 285 B |
BIN
vector/src/main/res/drawable-mdpi/ic_material_camera.png
Executable file
After Width: | Height: | Size: 539 B |
BIN
vector/src/main/res/drawable-mdpi/ic_material_file.png
Executable file
After Width: | Height: | Size: 545 B |
BIN
vector/src/main/res/drawable-mdpi/ic_material_videocam.png
Executable file
After Width: | Height: | Size: 3.0 KiB |
BIN
vector/src/main/res/drawable-mdpi/ic_send_sticker.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
vector/src/main/res/drawable-mdpi/vector_cancel_upload_download.png
Executable file
After Width: | Height: | Size: 309 B |
BIN
vector/src/main/res/drawable-mdpi/vector_micro_green.png
Executable file
After Width: | Height: | Size: 399 B |
BIN
vector/src/main/res/drawable-mdpi/video_call_black.png
Executable file
After Width: | Height: | Size: 224 B |
BIN
vector/src/main/res/drawable-mdpi/video_call_green.png
Executable file
After Width: | Height: | Size: 298 B |
BIN
vector/src/main/res/drawable-mdpi/voice_call_black.png
Executable file
After Width: | Height: | Size: 574 B |
BIN
vector/src/main/res/drawable-mdpi/voice_call_end_fushia.png
Executable file
After Width: | Height: | Size: 331 B |
BIN
vector/src/main/res/drawable-mdpi/voice_call_green.png
Executable file
After Width: | Height: | Size: 432 B |
BIN
vector/src/main/res/drawable-mdpi/voice_call_start_green.png
Executable file
After Width: | Height: | Size: 684 B |
BIN
vector/src/main/res/drawable-xhdpi/ic_attach_file_white.png
Normal file
After Width: | Height: | Size: 507 B |
BIN
vector/src/main/res/drawable-xxhdpi/ic_attach_file_white.png
Normal file
After Width: | Height: | Size: 809 B |
BIN
vector/src/main/res/drawable-xxxhdpi/ic_attach_file_white.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
62
vector/src/main/res/layout/dialog_export_e2e_keys.xml
Normal 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>
|
@ -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"
|
||||
|
53
vector/src/main/res/layout/item_dialog.xml
Normal 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>
|
@ -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>
|
@ -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>
|
@ -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>
|
||||
|