Browse Source

Merge pull request #1432 from vector-im/feature/bma/installApk

Install apk from the app - REQUEST_INSTALL_PACKAGES
pull/1439/head
Benoit Marty 12 months ago committed by GitHub
parent
commit
f607b557ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 54
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt
  2. 18
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt
  3. 19
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
  4. 10
      features/messages/impl/src/main/res/drawable/ic_apk_install.xml
  5. 8
      libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
  6. 3
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
  7. 1
      libraries/ui-strings/src/main/res/values/localazy.xml
  8. BIN
      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
  9. BIN
      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

54
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt

@ -16,6 +16,7 @@ @@ -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 @@ -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( @@ -50,10 +59,27 @@ class AndroidLocalMediaActions @Inject constructor(
) : LocalMediaActions {
private var activityContext: Context? = null
private var apkInstallLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>? = 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( @@ -99,11 +125,20 @@ class AndroidLocalMediaActions @Inject constructor(
override suspend fun open(localMedia: LocalMedia): Result<Unit> = 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( @@ -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"

18
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 @@ -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( @@ -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<LocalMedia>): Boolean { @@ -162,6 +165,7 @@ private fun rememberShowProgress(downloadedMedia: Async<LocalMedia>): Boolean {
@Composable
private fun MediaViewerTopBar(
actionsEnabled: Boolean,
mimeType: String,
onBackPressed: () -> Unit,
eventSink: (MediaViewerEvents) -> Unit,
) {
@ -175,10 +179,16 @@ private fun MediaViewerTopBar( @@ -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,

19
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( @@ -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(),

10
features/messages/impl/src/main/res/drawable/ic_apk_install.xml

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M160,880Q127,880 103.5,856.5Q80,833 80,800L80,160Q80,127 103.5,103.5Q127,80 160,80L480,80L720,320L720,490L640,490L640,360L440,360L440,160L160,160Q160,160 160,160Q160,160 160,160L160,800Q160,800 160,800Q160,800 160,800L600,800L600,880L160,880ZM160,800L160,490L160,490L160,360L160,160L160,160Q160,160 160,160Q160,160 160,160L160,800Q160,800 160,800Q160,800 160,800L160,800ZM200,760Q204,711 230,670Q256,629 298,605L260,537Q260,536 264,522Q269,520 273.5,520Q278,520 280,525L319,595Q339,587 359,582.5Q379,578 400,578Q421,578 441,582.5Q461,587 481,595L520,525Q520,525 535,521Q540,523 541,528Q542,533 540,537L502,605Q544,629 570,670Q596,711 600,760L200,760ZM310,700Q318,700 324,694Q330,688 330,680Q330,672 324,666Q318,660 310,660Q302,660 296,666Q290,672 290,680Q290,688 296,694Q302,700 310,700ZM490,700Q498,700 504,694Q510,688 510,680Q510,672 504,666Q498,660 490,660Q482,660 476,666Q470,672 470,680Q470,688 476,694Q482,700 490,700ZM800,880L640,720L696,663L760,726L760,560L840,560L840,726L904,663L960,720L800,880Z"/>
</vector>

8
libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt

@ -51,4 +51,12 @@ object MimeTypes { @@ -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
}
}
}

3
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt

@ -17,6 +17,7 @@ @@ -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( @@ -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)

1
libraries/ui-strings/src/main/res/values/localazy.xml

@ -94,6 +94,7 @@ @@ -94,6 +94,7 @@
<string name="common_gif">"GIF"</string>
<string name="common_image">"Image"</string>
<string name="common_in_reply_to">"In reply to %1$s"</string>
<string name="common_install_apk_android">"Install APK"</string>
<string name="common_invite_unknown_profile">"This Matrix ID can\'t be found, so the invite might not be received."</string>
<string name="common_leaving_room">"Leaving room"</string>
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save