android: open file in default app (#5413)

* android: open file in default app

* icon

* changes

* changes

* fix

* allow files without extension
This commit is contained in:
Stanislav Dmitrenko
2024-12-25 02:33:47 +07:00
committed by GitHub
parent e4044f6211
commit d80d2fa156
7 changed files with 177 additions and 8 deletions

View File

@@ -27,6 +27,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Allows to query app name and icon that can open specific file type -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="*/*" />
</intent>
</queries>
<application
android:name="SimplexApp"
android:allowBackup="false"

View File

@@ -3,19 +3,30 @@ package chat.simplex.common.platform
import android.Manifest
import android.content.*
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
import androidx.core.graphics.drawable.toBitmap
import chat.simplex.common.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import java.io.BufferedOutputStream
import java.io.File
import chat.simplex.res.MR
import java.net.URI
import kotlin.math.min
data class OpenDefaultApp(
val name: String,
val icon: ImageBitmap,
val isSystemChooser: Boolean
)
actual fun ClipboardManager.shareText(text: String) {
var text = text
for (i in 10 downTo 1) {
@@ -37,7 +48,7 @@ actual fun ClipboardManager.shareText(text: String) {
}
}
fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean) {
fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean, useChooser: Boolean = true) {
val uri = if (fileSource.cryptoArgs != null) {
val tmpFile = File(tmpDir, fileSource.filePath)
tmpFile.deleteOnExit()
@@ -67,9 +78,35 @@ fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean) {
type = mimeType
}
}
val shareIntent = Intent.createChooser(sendIntent, null)
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(shareIntent)
if (useChooser) {
val shareIntent = Intent.createChooser(sendIntent, null)
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(shareIntent)
} else {
sendIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(sendIntent)
}
}
fun queryDefaultAppForExtension(ext: String, encryptedFileUri: URI): OpenDefaultApp? {
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null
val openIntent = Intent(Intent.ACTION_VIEW)
openIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
openIntent.setDataAndType(encryptedFileUri.toUri(), mimeType)
val pm = androidAppContext.packageManager
//// This method returns the list of apps but no priority, nor default flag
// val resInfoList: List<ResolveInfo> = if (Build.VERSION.SDK_INT >= 33) {
// pm.queryIntentActivities(openIntent, PackageManager.ResolveInfoFlags.of((PackageManager.MATCH_DEFAULT_ONLY).toLong()))
// } else {
// pm.queryIntentActivities(openIntent, PackageManager.MATCH_DEFAULT_ONLY)
// }.sortedBy { it.priority }
// val first = resInfoList.firstOrNull { it.isDefault } ?: resInfoList.firstOrNull() ?: return null
val act = pm.resolveActivity(openIntent, PackageManager.MATCH_DEFAULT_ONLY) ?: return null
// Log.d(TAG, "Default launch action ${act} ${act.loadLabel(pm)} ${act.activityInfo?.name}")
val label = act.loadLabel(pm).toString()
val icon = act.loadIcon(pm).toBitmap().asImageBitmap()
val chooser = act.activityInfo?.name?.endsWith("ResolverActivity") == true
return OpenDefaultApp(label, icon, chooser)
}
actual fun shareFile(text: String, fileSource: CryptoFile) {

View File

@@ -0,0 +1,57 @@
package chat.simplex.common.views.chat.item
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.DefaultDropdownMenu
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import java.net.URI
@Composable
actual fun SaveOrOpenFileMenu(
showMenu: MutableState<Boolean>,
encrypted: Boolean,
ext: String?,
encryptedUri: URI,
fileSource: CryptoFile,
saveFile: () -> Unit
) {
val defaultApp = remember(encryptedUri.toString()) { if (ext != null) queryDefaultAppForExtension(ext, encryptedUri) else null }
DefaultDropdownMenu(showMenu) {
if (defaultApp != null) {
if (!defaultApp.isSystemChooser) {
ItemAction(
stringResource(MR.strings.open_with_app).format(defaultApp.name),
defaultApp.icon,
textColor = MaterialTheme.colors.primary,
onClick = {
openOrShareFile("", fileSource, justOpen = true, useChooser = false)
showMenu.value = false
}
)
} else {
ItemAction(
stringResource(MR.strings.open_with_app).format(""),
painterResource(MR.images.ic_open_in_new),
color = MaterialTheme.colors.primary,
onClick = {
openOrShareFile("", fileSource, justOpen = true, useChooser = false)
showMenu.value = false
}
)
}
}
ItemAction(
stringResource(MR.strings.save_verb),
painterResource(if (encrypted) MR.images.ic_lock_open_right else MR.images.ic_download),
color = MaterialTheme.colors.primary,
onClick = {
saveFile()
showMenu.value = false
}
)
}
}

View File

@@ -1,12 +1,12 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -184,14 +184,26 @@ fun CIFileView(
}
}
val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) }
val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() }
val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null
if (loadedFilePath != null && file?.fileSource != null) {
val encrypted = file.fileSource.cryptoArgs != null
SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() })
}
Row(
Modifier
.combinedClickable(
onClick = { fileAction() },
onClick = {
if (appPlatform.isAndroid && loadedFilePath != null) {
showOpenSaveMenu.value = true
} else {
fileAction()
}
},
onLongClick = { showMenu.value = true }
)
.padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())),
//Modifier.clickable(enabled = file?.fileSource != null) { if (file?.fileSource != null && getLoadedFilePath(file) != null) openFile(file.fileSource) }.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.sp.toDp())
) {
@@ -223,6 +235,16 @@ fun CIFileView(
fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol)
@Composable
expect fun SaveOrOpenFileMenu(
showMenu: MutableState<Boolean>,
encrypted: Boolean,
ext: String?,
encryptedUri: URI,
fileSource: CryptoFile,
saveFile: () -> Unit
)
@Composable
fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
rememberFileChooserLauncher(false, ciFile) { to: URI? ->

View File

@@ -867,6 +867,32 @@ fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, on
}
}
@Composable
fun ItemAction(text: String, icon: ImageBitmap, textColor: Color = Color.Unspecified, iconColor: Color = Color.Unspecified, onClick: () -> Unit) {
val finalColor = if (textColor == Color.Unspecified) {
MenuTextColor
} else textColor
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text,
modifier = Modifier
.fillMaxWidth()
.weight(1F)
.padding(end = 15.dp),
color = finalColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (iconColor == Color.Unspecified) {
Image(icon, text, Modifier.size(22.dp))
} else {
Icon(icon, text, Modifier.size(22.dp), tint = iconColor)
}
}
}
}
@Composable
fun ItemAction(
text: String,

View File

@@ -482,6 +482,7 @@
<string name="loading_remote_file_desc">Please, wait while the file is being loaded from the linked mobile</string>
<string name="file_error">File error</string>
<string name="temporary_file_error">Temporary file error</string>
<string name="open_with_app">Open with %s</string>
<!-- Voice messages -->
<string name="voice_message">Voice message</string>

View File

@@ -0,0 +1,18 @@
package chat.simplex.common.views.chat.item
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import chat.simplex.common.model.CryptoFile
import java.net.URI
@Composable
actual fun SaveOrOpenFileMenu(
showMenu: MutableState<Boolean>,
encrypted: Boolean,
ext: String?,
encryptedUri: URI,
fileSource: CryptoFile,
saveFile: () -> Unit
) {
}