Commit 20b14673 authored by Lucio Maciel's avatar Lucio Maciel

Improve file upload. Respect FileUpload_MediaTypeWhiteList and FileUpload_MaxFileSize

parent befa16fc
......@@ -4,66 +4,41 @@ import chat.rocket.android.authentication.presentation.AuthenticationNavigator
import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.helper.NetworkHelper
import chat.rocket.android.helper.UrlHelper
import chat.rocket.android.server.domain.*
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.android.server.domain.RefreshSettingsInteractor
import chat.rocket.android.server.domain.SaveCurrentServerInteractor
import chat.rocket.android.util.extensions.launchUI
import chat.rocket.common.util.ifNull
import chat.rocket.core.RocketChatClient
import chat.rocket.core.internal.rest.settings
import java.security.InvalidParameterException
import javax.inject.Inject
class ServerPresenter @Inject constructor(private val view: ServerView,
private val strategy: CancelStrategy,
private val navigator: AuthenticationNavigator,
private val serverInteractor: SaveCurrentServerInteractor,
private val settingsInteractor: SaveSettingsInteractor,
private val factory: RocketChatClientFactory) {
private lateinit var client: RocketChatClient
private var settingsFilter = arrayOf(SITE_URL, SITE_NAME, FAVICON_512, USE_REALNAME, ALLOW_ROOM_NAME_SPECIAL_CHARS, FAVORITE_ROOMS,
ACCOUNT_LOGIN_FORM, ACCOUNT_GOOGLE, ACCOUNT_FACEBOOK, ACCOUNT_GITHUB, ACCOUNT_GITLAB, ACCOUNT_LINKEDIN, ACCOUNT_METEOR,
ACCOUNT_TWITTER, ACCOUNT_WORDPRESS, LDAP_ENABLE, ACCOUNT_REGISTRATION, STORAGE_TYPE, HIDE_USER_JOIN, HIDE_USER_LEAVE, HIDE_TYPE_AU,
HIDE_MUTE_UNMUTE, HIDE_TYPE_RU, ACCOUNT_CUSTOM_FIELDS, ALLOW_MESSAGE_DELETING, ALLOW_MESSAGE_EDITING, ALLOW_MESSAGE_PINNING,
SHOW_DELETED_STATUS, SHOW_EDITED_STATUS)
private val refreshSettingsInteractor: RefreshSettingsInteractor) {
fun connect(server: String) {
if (!UrlHelper.isValidUrl(server)) {
view.showInvalidServerUrl()
} else {
try {
client = factory.create(server)
} catch (exception: InvalidParameterException) {
exception.message?.let {
view.showMessage(it)
}.ifNull {
view.showGenericErrorMessage()
}
return
}
client.let { rocketChatClient ->
launchUI(strategy) {
if (NetworkHelper.hasInternetAccess()) {
view.showLoading()
launchUI(strategy) {
if (NetworkHelper.hasInternetAccess()) {
view.showLoading()
try {
val settings = rocketChatClient.settings(*settingsFilter)
settingsInteractor.save(server, settings)
serverInteractor.save(server)
try {
refreshSettingsInteractor.refresh(server)
serverInteractor.save(server)
navigator.toLogin()
} catch (exception: Exception) {
exception.message?.let {
view.showMessage(it)
}.ifNull {
view.showGenericErrorMessage()
}
} finally {
view.hideLoading()
navigator.toLogin()
} catch (ex: Exception) {
ex.message?.let {
view.showMessage(it)
}.ifNull {
view.showGenericErrorMessage()
}
} else {
view.showNoInternetConnection()
} finally {
view.hideLoading()
}
} else {
view.showNoInternetConnection()
}
}
}
......
......@@ -2,11 +2,7 @@ package chat.rocket.android.chatroom.domain
import android.content.Context
import android.net.Uri
import chat.rocket.android.util.extensions.getFileName
import chat.rocket.android.util.extensions.getMimeType
import chat.rocket.android.util.extensions.getRealPathFromURI
import okio.Okio
import java.io.File
import chat.rocket.android.util.extensions.*
import javax.inject.Inject
......@@ -28,26 +24,7 @@ class UriInteractor @Inject constructor(private val context: Context) {
*/
fun getRealPath(uri: Uri): String? = uri.getRealPathFromURI(context)
/**
* Save the contents of an [Uri] to a temp file named after uri.getFileName()
*/
fun tempFile(uri: Uri): File? {
try {
val outputDir = context.cacheDir // context being the Activity pointer
val outputFile = File(outputDir, uri.getFileName(context))
val from = context.contentResolver.openInputStream(uri)
Okio.source(from).use { a ->
Okio.buffer(Okio.sink(outputFile)).use{ b ->
b.writeAll(a)
b.close()
}
a.close()
}
return outputFile
} catch (ex: Exception) {
ex.printStackTrace()
return null
}
}
fun getFileSize(uri: Uri) = uri.getFileSize(context)
fun getInputStream(uri: Uri) = uri.getInputStream(context)
}
\ No newline at end of file
......@@ -24,7 +24,6 @@ import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.channels.Channel
import kotlinx.coroutines.experimental.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject
class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
......@@ -38,7 +37,7 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
private val mapper: MessageViewModelMapper) {
private val client = factory.create(serverInteractor.get()!!)
private var subId: String? = null
private var settings: Map<String, Value<Any>> = getSettingsInteractor.get(serverInteractor.get()!!)!!
private val settings: Map<String, Value<Any>> = getSettingsInteractor.get(serverInteractor.get()!!)!!
private val stateChannel = Channel<State>()
......@@ -93,32 +92,28 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
}
}
fun selectFile() {
view.showFileSelection(settings.uploadMimeTypeFilter())
}
fun uploadFile(roomId: String, uri: Uri, msg: String) {
launchUI(strategy) {
view.showLoading()
var tempFile: File? = null
try {
val fileName = async { uriInteractor.getFileName(uri) }.await()
val mimeType = async { uriInteractor.getMimeType(uri) }.await()
/* FIXME - this is a workaround for uploading files with the SDK
*
* https://developer.android.com/guide/topics/providers/document-provider.html
*
* We need to use contentResolver.openInputStream(uri) to open this file.
* Since the SDK is not Android specific we cannot pass the Uri and let the
* SDK handle the file.
*
* As a temporary workaround we are saving the contents to a temp file.
*
* A proper solution is to implement some interface to open the InputStream
* and use a RequestBody based on https://github.com/square/okhttp/issues/3585
*/
tempFile = async { uriInteractor.tempFile(uri) }.await()
val fileSize = async { uriInteractor.getFileSize(uri) }.await()
val maxFileSize = settings.uploadMaxFileSize()
if (fileName == null || tempFile == null) {
view.showInvalidFileMessage()
} else {
client.uploadFile(roomId, tempFile, mimeType, msg, fileName)
when {
fileName.isNullOrEmpty() -> view.showInvalidFileMessage()
fileSize > maxFileSize -> view.showInvalidFileSize(fileSize, maxFileSize)
else -> {
Timber.d("Uploading to $roomId: $fileName - $mimeType")
client.uploadFile(roomId, fileName!!, mimeType, msg, description = fileName) {
uriInteractor.getInputStream(uri)
}
}
}
} catch (ex: RocketChatException) {
Timber.d(ex)
......@@ -128,13 +123,12 @@ class ChatRoomPresenter @Inject constructor(private val view: ChatRoomView,
view.showGenericErrorMessage()
}
} finally {
tempFile?.delete()
view.hideLoading()
}
}
}
fun subscribeMessages(roomId: String) {
private fun subscribeMessages(roomId: String) {
client.addStateChannel(stateChannel)
launch(CommonPool + strategy.jobs) {
for (status in stateChannel) {
......
......@@ -21,6 +21,11 @@ interface ChatRoomView : LoadingView, MessageView {
*/
fun sendMessage(text: String)
/**
* Perform file selection with the mime type [filter]
*/
fun showFileSelection(filter: Array<String>)
/**
* Uploads a file to a chat room.
*
......@@ -78,4 +83,6 @@ interface ChatRoomView : LoadingView, MessageView {
fun disableMessageInput()
fun enableMessageInput(clear: Boolean = false)
fun showInvalidFileSize(fileSize: Int, maxFileSize: Int)
}
\ No newline at end of file
......@@ -214,7 +214,17 @@ class ChatRoomFragment : Fragment(), ChatRoomView {
text_message.textContent = text
editingMessageId = messageId
}
}
override fun showFileSelection(filter: Array<String>) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "*/*"
intent.putExtra(Intent.EXTRA_MIME_TYPES, filter)
startActivityForResult(intent, REQUEST_CODE_FOR_PERFORM_SAF)
}
override fun showInvalidFileSize(fileSize: Int, maxFileSize: Int) {
showMessage(getString(R.string.max_file_size_exceeded, fileSize, maxFileSize))
}
private fun setupComposer() {
......@@ -258,7 +268,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView {
button_files.setOnClickListener {
handler.postDelayed({
performSAF()
presenter.selectFile()
}, 300)
handler.postDelayed({
......@@ -297,10 +307,4 @@ class ChatRoomFragment : Fragment(), ChatRoomView {
view_dim.setVisible(false)
}
private fun performSAF() {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "*/*"
startActivityForResult(intent, REQUEST_CODE_FOR_PERFORM_SAF)
}
}
\ No newline at end of file
......@@ -4,6 +4,7 @@ import chat.rocket.android.core.lifecycle.CancelStrategy
import chat.rocket.android.infrastructure.LocalRepository
import chat.rocket.android.server.domain.GetChatRoomsInteractor
import chat.rocket.android.server.domain.GetCurrentServerInteractor
import chat.rocket.android.server.domain.RefreshSettingsInteractor
import chat.rocket.android.server.domain.SaveChatRoomsInteractor
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.android.util.extensions.launchUI
......@@ -28,6 +29,7 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
private val getChatRoomsInteractor: GetChatRoomsInteractor,
private val saveChatRoomsInteractor: SaveChatRoomsInteractor,
private val localRepository: LocalRepository,
private val refreshSettingsInteractor: RefreshSettingsInteractor,
factory: RocketChatClientFactory) {
private val client: RocketChatClient = factory.create(serverInteractor.get()!!)
private val currentServer = serverInteractor.get()!!
......@@ -36,6 +38,7 @@ class ChatRoomsPresenter @Inject constructor(private val view: ChatRoomsView,
private val stateChannel = Channel<State>()
fun loadChatRooms() {
refreshSettingsInteractor.refreshAsync(currentServer)
launchUI(strategy) {
view.showLoading()
try {
......
package chat.rocket.android.server.domain
import chat.rocket.android.server.infraestructure.RocketChatClientFactory
import chat.rocket.core.internal.rest.settings
import kotlinx.coroutines.experimental.CommonPool
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.withContext
import javax.inject.Inject
class RefreshSettingsInteractor @Inject constructor(private val factory: RocketChatClientFactory,
private val repository: SettingsRepository) {
private var settingsFilter = arrayOf(
SITE_URL, SITE_NAME, FAVICON_512, USE_REALNAME, ALLOW_ROOM_NAME_SPECIAL_CHARS,
FAVORITE_ROOMS, ACCOUNT_LOGIN_FORM, ACCOUNT_GOOGLE, ACCOUNT_FACEBOOK, ACCOUNT_GITHUB,
ACCOUNT_GITLAB, ACCOUNT_LINKEDIN, ACCOUNT_METEOR, ACCOUNT_TWITTER, ACCOUNT_WORDPRESS,
LDAP_ENABLE, ACCOUNT_REGISTRATION, UPLOAD_STORAGE_TYPE, UPLOAD_MAX_FILE_SIZE,
UPLOAD_WHITELIST_MIMETYPES, HIDE_USER_JOIN, HIDE_USER_LEAVE, HIDE_TYPE_AU, HIDE_MUTE_UNMUTE,
HIDE_TYPE_RU, ACCOUNT_CUSTOM_FIELDS, ALLOW_MESSAGE_DELETING, ALLOW_MESSAGE_EDITING,
ALLOW_MESSAGE_PINNING, SHOW_DELETED_STATUS, SHOW_EDITED_STATUS)
suspend fun refresh(server: String) {
withContext(CommonPool) {
factory.create(server).let { client ->
val settings = client.settings(*settingsFilter)
repository.save(server, settings)
}
}
}
fun refreshAsync(server: String) {
async {
try {
refresh(server)
} catch (ex: Exception) {
ex.printStackTrace()
}
}
}
}
\ No newline at end of file
package chat.rocket.android.server.domain
import chat.rocket.android.util.extensions.mapToTypedArray
import chat.rocket.core.model.Value
typealias PublicSettings = Map<String, Value<Any>>
......@@ -28,7 +29,9 @@ const val USE_REALNAME = "UI_Use_Real_Name"
const val ALLOW_ROOM_NAME_SPECIAL_CHARS = "UI_Allow_room_names_with_special_chars"
const val FAVORITE_ROOMS = "Favorite_Rooms"
const val LDAP_ENABLE = "LDAP_Enable"
const val STORAGE_TYPE = "FileUpload_Storage_Type"
const val UPLOAD_STORAGE_TYPE = "FileUpload_Storage_Type"
const val UPLOAD_MAX_FILE_SIZE = "FileUpload_MaxFileSize"
const val UPLOAD_WHITELIST_MIMETYPES = "FileUpload_MediaTypeWhiteList"
const val HIDE_USER_JOIN = "Message_HideType_uj"
const val HIDE_USER_LEAVE = "Message_HideType_ul"
const val HIDE_TYPE_AU = "Message_HideType_au"
......@@ -66,4 +69,17 @@ fun Map<String, Value<Any>>.allowedMessageDeleting(): Boolean = this[ALLOW_MESSA
fun Map<String, Value<Any>>.registrationEnabled(): Boolean {
val value = this[ACCOUNT_REGISTRATION]
return value?.value == "Public"
}
fun Map<String, Value<Any>>.uploadMimeTypeFilter(): Array<String> {
val values = this[UPLOAD_WHITELIST_MIMETYPES]?.value
values?.let { it as String }?.split(",")?.let {
return it.mapToTypedArray { it.trim() }
}
return arrayOf("*/*")
}
fun Map<String, Value<Any>>.uploadMaxFileSize(): Int {
return this[UPLOAD_MAX_FILE_SIZE]?.value?.let { it as Int } ?: Int.MAX_VALUE
}
\ No newline at end of file
package chat.rocket.android.util.extensions
inline fun <T, reified R> List<T>.mapToTypedArray(transform: (T) -> R): Array<R> {
return when (this) {
is RandomAccess -> Array(size) { index -> transform(this[index]) }
else -> with(iterator()) { Array(size) { transform(next()) } }
}
}
\ No newline at end of file
package chat.rocket.android.util.extensions
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.widget.TextView
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import android.provider.MediaStore
import ru.noties.markwon.Markwon
fun String.ifEmpty(value: String): String {
......@@ -43,50 +37,4 @@ var TextView.content: CharSequence
text = value
Markwon.scheduleDrawables(this)
Markwon.scheduleTableRows(this)
}
fun Uri.getFileName(context: Context): String? {
val cursor = context.contentResolver.query(this, null, null, null, null, null)
var fileName: String? = null
cursor.use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}
return fileName
}
fun Uri.getFileSize(context: Context): String? {
val cursor = context.contentResolver.query(this, null, null, null, null, null)
var fileSize: String? = null
cursor.use { cursor ->
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor != null && cursor.moveToFirst()) {
if (!cursor.isNull(sizeIndex)) {
fileSize = cursor.getString(sizeIndex)
}
}
}
return fileSize
}
fun Uri.getMimeType(context: Context): String {
return if (scheme == ContentResolver.SCHEME_CONTENT) {
context.contentResolver.getType(this)
} else {
val fileExtension = MimeTypeMap.getFileExtensionFromUrl(toString())
MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.toLowerCase())
}
}
fun Uri.getRealPathFromURI(context: Context): String? {
val cursor = context.contentResolver.query(this, arrayOf(MediaStore.Images.Media.DATA), null, null, null)
cursor.use { cursor ->
val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
cursor.moveToFirst()
return cursor.getString(columnIndex)
}
}
\ No newline at end of file
}
\ No newline at end of file
package chat.rocket.android.util.extensions
import android.annotation.TargetApi
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
fun Uri.getFileName(context: Context): String? {
val cursor = context.contentResolver.query(this, null, null, null, null, null)
var fileName: String? = null
cursor.use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
fileName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}
return fileName
}
fun Uri.getFileSize(context: Context): Int {
val cursor = context.contentResolver.query(this, null, null, null, null, null)
var fileSize: String? = null
cursor.use { cursor ->
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor != null && cursor.moveToFirst()) {
if (!cursor.isNull(sizeIndex)) {
fileSize = cursor.getString(sizeIndex)
}
}
}
return fileSize?.toIntOrNull() ?: -1
}
fun Uri.getMimeType(context: Context): String {
return if (scheme == ContentResolver.SCHEME_CONTENT) {
context.contentResolver.getType(this)
} else {
val fileExtension = MimeTypeMap.getFileExtensionFromUrl(toString())
MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.toLowerCase())
}
}
fun Uri.getRealPathFromURI(context: Context): String? {
val cursor = context.contentResolver.query(this, arrayOf(MediaStore.Images.Media.DATA), null, null, null)
cursor.use { cursor ->
val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
cursor.moveToFirst()
return cursor.getString(columnIndex)
}
}
@TargetApi(Build.VERSION_CODES.N)
fun Uri.isVirtualFile(context: Context): Boolean {
if (!DocumentsContract.isDocumentUri(context, this)) {
return false
}
val cursor = context.contentResolver.query(this,
arrayOf(DocumentsContract.Document.COLUMN_FLAGS),
null, null, null)
var flags = 0
if (cursor.moveToFirst()) {
flags = cursor.getInt(0)
}
cursor.close()
return flags and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT != 0
}
@Throws(IOException::class)
fun Uri.getInputStreamForVirtualFile(context: Context, mimeTypeFilter: String): FileInputStream? {
val resolver = context.contentResolver
val openableMimeTypes = resolver.getStreamTypes(this, mimeTypeFilter)
if (openableMimeTypes == null || openableMimeTypes.isEmpty()) {
throw FileNotFoundException()
}
return resolver.openTypedAssetFileDescriptor(this, openableMimeTypes[0],
null)?.createInputStream()
}
fun Uri.getInputStream(context: Context): InputStream? {
if (isVirtualFile(context)) {
return getInputStreamForVirtualFile(context, "*/*")
}
return context.contentResolver.openInputStream(this)
}
\ No newline at end of file
......@@ -82,4 +82,7 @@
<!-- Pinned Messages -->
<string name="title_pinned_messages">Pinned Messages</string>
<!-- Upload Messages -->
<string name="max_file_size_exceeded">File size %1$d exceeded max file size of %2$d bytes</string>
</resources>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment