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..b060ea0bb5 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 { + open(it) + } + } + } else { + // User cancelled + } + pendingMedia = null + } return DisposableEffect(Unit) { activityContext = context onDispose { @@ -99,11 +125,21 @@ 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!!) + Unit + } else { + openFile(localMedia) + } + } else { + openFile(localMedia) + } + } + else -> openFile(localMedia) } }.onSuccess { Timber.v("Open media succeed") @@ -112,6 +148,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/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/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 5d4d427af9..b38e0e3796 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -93,6 +93,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"