Add image viewer gallery actions

This commit is contained in:
Курнат Андрей
2026-07-21 16:01:10 +03:00
parent e59ca21c2a
commit 43a857afb5
4 changed files with 128 additions and 10 deletions
+1
View File
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
@@ -78,6 +78,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Campaign
@@ -376,9 +377,34 @@ private fun QMaxApp(
val context = LocalContext.current
var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) }
var notificationPermissionRequested by rememberSaveable { mutableStateOf(false) }
var pendingGalleryAttachment by remember { mutableStateOf<AttachmentDto?>(null) }
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
notificationPermissionRequested = true
}
val galleryPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val attachment = pendingGalleryAttachment
pendingGalleryAttachment = null
if (granted && attachment != null) {
vm.saveImageToGallery(attachment) {
Toast.makeText(context, "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u043e \u0432 Pictures/Qmax", Toast.LENGTH_SHORT).show()
}
} else if (!granted) {
Toast.makeText(context, "\u041d\u0435\u0442 \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u044f \u043d\u0430 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435", Toast.LENGTH_SHORT).show()
}
}
fun saveImageToGallery(attachment: AttachmentDto) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
) {
vm.saveImageToGallery(attachment) {
Toast.makeText(context, "\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u043e \u0432 Pictures/Qmax", Toast.LENGTH_SHORT).show()
}
} else {
pendingGalleryAttachment = attachment
galleryPermission.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
LaunchedEffect(state.session?.serverUrl, state.session?.userName) {
if (state.session == null) {
@@ -428,8 +454,8 @@ private fun QMaxApp(
total = state.previewImageGallery.size,
onPrevious = { vm.moveImagePreview(-1) },
onNext = { vm.moveImagePreview(1) },
onSave = previewAttachment?.let { attachment -> { saveImageToGallery(attachment) } },
onShare = previewAttachment?.let { attachment -> { vm.shareAttachment(attachment) } },
onOpen = previewAttachment?.let { attachment -> { vm.openAttachment(attachment) } },
onClose = vm::closeImage
)
}
@@ -6165,8 +6191,8 @@ private fun ImagePreview(
total: Int,
onPrevious: () -> Unit,
onNext: () -> Unit,
onSave: (() -> Unit)?,
onShare: (() -> Unit)?,
onOpen: (() -> Unit)?,
onClose: () -> Unit
) {
BackHandler(onBack = onClose)
@@ -6178,6 +6204,7 @@ private fun ImagePreview(
var scale by remember(url) { mutableStateOf(1f) }
var offset by remember(url) { mutableStateOf(Offset.Zero) }
var horizontalDrag by remember(url) { mutableStateOf(0f) }
var menuOpen by remember(url) { mutableStateOf(false) }
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
val nextScale = (scale * zoomChange).coerceIn(1f, 5f)
scale = nextScale
@@ -6270,14 +6297,33 @@ private fun ImagePreview(
style = MaterialTheme.typography.labelMedium
)
}
onShare?.let { share ->
IconButton(onClick = share, modifier = Modifier.size(44.dp)) {
Icon(Icons.Filled.Share, contentDescription = "Поделиться", tint = Color.White)
}
}
onOpen?.let { open ->
TextButton(onClick = open) {
Text("Открыть", color = Color.White)
if (onSave != null || onShare != null) {
Box {
IconButton(onClick = { menuOpen = true }, modifier = Modifier.size(44.dp)) {
Icon(Icons.Filled.MoreVert, contentDescription = "Меню", tint = Color.White)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
onSave?.let { save ->
DropdownMenuItem(
text = { Text("Сохранить в галерею") },
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null) },
onClick = {
menuOpen = false
save()
}
)
}
onShare?.let { share ->
DropdownMenuItem(
text = { Text("Поделиться") },
leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
onClick = {
menuOpen = false
share()
}
)
}
}
}
}
}
@@ -4,7 +4,12 @@ import android.content.Context
import android.content.Intent
import android.content.ClipData
import android.content.ActivityNotFoundException
import android.content.ContentValues
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import androidx.core.content.FileProvider
@@ -340,6 +345,51 @@ class QMaxRepository(
sharePayload(session = session, text = null, attachments = listOf(attachment))
}
suspend fun saveImageToGallery(session: QMaxSession, attachment: AttachmentDto): Uri {
val source = cachedAttachmentFile(session, attachment)
val mimeType = attachment.contentType.substringBefore(';').trim().takeIf { it.startsWith("image/") }
?: "image/jpeg"
val displayName = File(attachment.fileName.substringAfterLast('/')).name.trim().ifBlank {
"qmax-${System.currentTimeMillis()}.${mimeType.substringAfter('/', "jpg")}"
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
val directory = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
"Qmax"
)
if (!directory.exists() && !directory.mkdirs()) {
error("Не удалось создать папку Pictures/Qmax")
}
val target = uniqueGalleryFile(directory, displayName)
source.copyTo(target, overwrite = false)
MediaScannerConnection.scanFile(context, arrayOf(target.absolutePath), arrayOf(mimeType), null)
return Uri.fromFile(target)
}
val resolver = context.contentResolver
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
put(MediaStore.Images.Media.MIME_TYPE, mimeType)
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Qmax")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: error("Не удалось создать изображение в галерее")
try {
resolver.openOutputStream(uri, "w")?.use { output ->
source.inputStream().use { input -> input.copyTo(output) }
} ?: error("Не удалось записать изображение в галерею")
resolver.update(uri, ContentValues().apply {
put(MediaStore.Images.Media.IS_PENDING, 0)
}, null, null)
return uri
} catch (error: Throwable) {
resolver.delete(uri, null, null)
throw error
}
}
suspend fun maxStatus(session: QMaxSession): MaxBridgeStatusDto {
return withFreshSession(session) { api.maxStatus(it.serverUrl, it.accessToken) }
}
@@ -440,6 +490,21 @@ class QMaxRepository(
}
}
private fun uniqueGalleryFile(directory: File, displayName: String): File {
val initial = directory.resolve(displayName)
if (!initial.exists()) return initial
val extension = displayName.substringAfterLast('.', missingDelimiterValue = "")
val baseName = if (extension.isBlank()) displayName else displayName.removeSuffix(".$extension")
var suffix = 2
while (true) {
val candidateName = if (extension.isBlank()) "$baseName ($suffix)" else "$baseName ($suffix).$extension"
val candidate = directory.resolve(candidateName)
if (!candidate.exists()) return candidate
suffix += 1
}
}
private fun commonShareMime(types: List<String>): String {
val normalized = types.map { it.takeIf(String::isNotBlank) ?: "application/octet-stream" }
if (normalized.isEmpty()) return "*/*"
@@ -1016,6 +1016,12 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
repository.shareAttachment(session, attachment)
}
fun saveImageToGallery(attachment: AttachmentDto, onSuccess: () -> Unit = {}) = launchLoading(false) {
val session = requireSession()
repository.saveImageToGallery(session, attachment)
onSuccess()
}
fun replyTo(message: MessageDto) {
state.value = state.value.copy(replyTarget = message, editTarget = null)
}