diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt index 44bff4f5ab..ef93574134 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt @@ -16,6 +16,7 @@ package io.element.android.features.messages.impl.media.local +import android.app.Activity import android.content.ContentResolver import android.content.ContentValues import android.content.Context @@ -24,17 +25,25 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.core.content.FileProvider import androidx.core.net.toFile import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.system.startInstallFromSourceIntent import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File @@ -50,10 +59,27 @@ class AndroidLocalMediaActions @Inject constructor( ) : LocalMediaActions { private var activityContext: Context? = null + private var apkInstallLauncher: ManagedActivityResultLauncher? = null + private var pendingMedia: LocalMedia? = null @Composable override fun Configure() { val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + apkInstallLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + pendingMedia?.let { + coroutineScope.launch { + openFile(it) + } + } + } else { + // User cancelled + } + pendingMedia = null + } return DisposableEffect(Unit) { activityContext = context onDispose { @@ -99,11 +125,20 @@ class AndroidLocalMediaActions @Inject constructor( override suspend fun open(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) runCatching { - val openMediaIntent = Intent(Intent.ACTION_VIEW) - .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .setDataAndType(localMedia.toShareableUri(), localMedia.info.mimeType) - withContext(coroutineDispatchers.main) { - activityContext!!.startActivity(openMediaIntent) + when (localMedia.info.mimeType) { + MimeTypes.Apk -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (activityContext?.packageManager?.canRequestPackageInstalls() == false) { + pendingMedia = localMedia + activityContext?.startInstallFromSourceIntent(apkInstallLauncher!!).let { } + } else { + openFile(localMedia) + } + } else { + openFile(localMedia) + } + } + else -> openFile(localMedia) } }.onSuccess { Timber.v("Open media succeed") @@ -112,6 +147,15 @@ class AndroidLocalMediaActions @Inject constructor( } } + private suspend fun openFile(localMedia: LocalMedia) { + val openMediaIntent = Intent(Intent.ACTION_VIEW) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setDataAndType(localMedia.toShareableUri(), localMedia.info.mimeType) + withContext(coroutineDispatchers.main) { + activityContext?.startActivity(openMediaIntent) + } + } + private fun LocalMedia.toShareableUri(): Uri { val mediaAsFile = this.toFile() val authority = "${buildMeta.applicationId}.fileprovider" diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt index 7ab52216fe..b0570a6f2a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt @@ -47,11 +47,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMediaView import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -92,6 +94,7 @@ fun MediaViewerView( topBar = { MediaViewerTopBar( actionsEnabled = state.downloadedMedia is Async.Success, + mimeType = state.mediaInfo.mimeType, onBackPressed = onBackPressed, eventSink = state.eventSink ) @@ -162,6 +165,7 @@ private fun rememberShowProgress(downloadedMedia: Async): Boolean { @Composable private fun MediaViewerTopBar( actionsEnabled: Boolean, + mimeType: String, onBackPressed: () -> Unit, eventSink: (MediaViewerEvents) -> Unit, ) { @@ -175,10 +179,16 @@ private fun MediaViewerTopBar( eventSink(MediaViewerEvents.OpenWith) }, ) { - Icon( - imageVector = Icons.Default.OpenInNew, - contentDescription = stringResource(id = CommonStrings.action_open_with) - ) + when (mimeType) { + MimeTypes.Apk -> Icon( + resourceId = R.drawable.ic_apk_install, + contentDescription = stringResource(id = CommonStrings.common_install_apk_android) + ) + else -> Icon( + imageVector = Icons.Default.OpenInNew, + contentDescription = stringResource(id = CommonStrings.action_open_with) + ) + } } IconButton( enabled = actionsEnabled, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 040969e092..3d0dc4d80c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -109,14 +109,17 @@ class TimelineItemContentMessageFactory @Inject constructor( formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), fileExtension = fileExtensionExtractor.extractFromName(messageType.body) ) - is FileMessageType -> TimelineItemFileContent( - body = messageType.body, - thumbnailSource = messageType.info?.thumbnailSource, - fileSource = messageType.source, - mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, - formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), - fileExtension = fileExtensionExtractor.extractFromName(messageType.body) - ) + is FileMessageType -> { + val fileExtension = fileExtensionExtractor.extractFromName(messageType.body) + TimelineItemFileContent( + body = messageType.body, + thumbnailSource = messageType.info?.thumbnailSource, + fileSource = messageType.source, + mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension), + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtension + ) + } is NoticeMessageType -> TimelineItemNoticeContent( body = messageType.body, htmlDocument = messageType.formatted?.toHtmlDocument(), diff --git a/features/messages/impl/src/main/res/drawable/ic_apk_install.xml b/features/messages/impl/src/main/res/drawable/ic_apk_install.xml new file mode 100644 index 0000000000..b39fc4c5d5 --- /dev/null +++ b/features/messages/impl/src/main/res/drawable/ic_apk_install.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index c6373fbbf6..0506126568 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -51,4 +51,12 @@ object MimeTypes { fun String?.isMimeTypeFile() = this?.startsWith("file/").orFalse() fun String?.isMimeTypeText() = this?.startsWith("text/").orFalse() fun String?.isMimeTypeAny() = this?.startsWith("*/").orFalse() + + fun fromFileExtension(fileExtension: String): String { + return when (fileExtension.lowercase()) { + "apk" -> Apk + "pdf" -> Pdf + else -> OctetStream + } + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index c958810f5e..97148f7dd4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.impl.media import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource @@ -77,7 +78,7 @@ class RustMediaLoader( val mediaFile = innerClient.getMediaFile( mediaSource = mediaSource, body = body, - mimeType = mimeType ?: "application/octet-stream", + mimeType = mimeType ?: MimeTypes.OctetStream, tempDir = cacheDirectory.path, ) RustMediaFile(mediaFile) diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 48c518deed..18f5e63078 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -94,6 +94,7 @@ "GIF" "Image" "In reply to %1$s" + "Install APK" "This Matrix ID can\'t be found, so the invite might not be received." "Leaving room" "Link copied to clipboard" diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.media.viewer_null_MediaViewerView_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.media.viewer_null_MediaViewerView_0_null_6,NEXUS_5,1.0,en].png index 6715960e25..a322a9a364 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.media.viewer_null_MediaViewerView_0_null_6,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.media.viewer_null_MediaViewerView_0_null_6,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:15ec8227e84c01b8e9acd5271e379d989c8e4ed72b57c87d252a992a0d3f1a1a -size 15702 +oid sha256:bd2a831abc63de6366f2a4fbac653d551e29fbbbb698f5dc3ae51de04dbf6138 +size 15941 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.media.viewer_null_MediaViewerView_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.media.viewer_null_MediaViewerView_0_null_7,NEXUS_5,1.0,en].png index 2aca1f16a4..01188c15b3 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.media.viewer_null_MediaViewerView_0_null_7,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.media.viewer_null_MediaViewerView_0_null_7,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08c11b272ebe9b4d8f9b76841b60a519d08a9e5f37970c66b78bd36319bcd53f -size 15782 +oid sha256:6b7dea2e1df15375ae07db4e6cf7b2a14a26c0ff1f2cbb11efdaea263d954550 +size 16067