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_CONTACTS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <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" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <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.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll 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.Edit
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Campaign import androidx.compose.material.icons.filled.Campaign
@@ -376,9 +377,34 @@ private fun QMaxApp(
val context = LocalContext.current val context = LocalContext.current
var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) } var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) }
var notificationPermissionRequested by rememberSaveable { mutableStateOf(false) } var notificationPermissionRequested by rememberSaveable { mutableStateOf(false) }
var pendingGalleryAttachment by remember { mutableStateOf<AttachmentDto?>(null) }
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
notificationPermissionRequested = true 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) { LaunchedEffect(state.session?.serverUrl, state.session?.userName) {
if (state.session == null) { if (state.session == null) {
@@ -428,8 +454,8 @@ private fun QMaxApp(
total = state.previewImageGallery.size, total = state.previewImageGallery.size,
onPrevious = { vm.moveImagePreview(-1) }, onPrevious = { vm.moveImagePreview(-1) },
onNext = { vm.moveImagePreview(1) }, onNext = { vm.moveImagePreview(1) },
onSave = previewAttachment?.let { attachment -> { saveImageToGallery(attachment) } },
onShare = previewAttachment?.let { attachment -> { vm.shareAttachment(attachment) } }, onShare = previewAttachment?.let { attachment -> { vm.shareAttachment(attachment) } },
onOpen = previewAttachment?.let { attachment -> { vm.openAttachment(attachment) } },
onClose = vm::closeImage onClose = vm::closeImage
) )
} }
@@ -6165,8 +6191,8 @@ private fun ImagePreview(
total: Int, total: Int,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onSave: (() -> Unit)?,
onShare: (() -> Unit)?, onShare: (() -> Unit)?,
onOpen: (() -> Unit)?,
onClose: () -> Unit onClose: () -> Unit
) { ) {
BackHandler(onBack = onClose) BackHandler(onBack = onClose)
@@ -6178,6 +6204,7 @@ private fun ImagePreview(
var scale by remember(url) { mutableStateOf(1f) } var scale by remember(url) { mutableStateOf(1f) }
var offset by remember(url) { mutableStateOf(Offset.Zero) } var offset by remember(url) { mutableStateOf(Offset.Zero) }
var horizontalDrag by remember(url) { mutableStateOf(0f) } var horizontalDrag by remember(url) { mutableStateOf(0f) }
var menuOpen by remember(url) { mutableStateOf(false) }
val transformState = rememberTransformableState { zoomChange, panChange, _ -> val transformState = rememberTransformableState { zoomChange, panChange, _ ->
val nextScale = (scale * zoomChange).coerceIn(1f, 5f) val nextScale = (scale * zoomChange).coerceIn(1f, 5f)
scale = nextScale scale = nextScale
@@ -6270,14 +6297,33 @@ private fun ImagePreview(
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.labelMedium
) )
} }
onShare?.let { share -> if (onSave != null || onShare != null) {
IconButton(onClick = share, modifier = Modifier.size(44.dp)) { Box {
Icon(Icons.Filled.Share, contentDescription = "Поделиться", tint = Color.White) IconButton(onClick = { menuOpen = true }, modifier = Modifier.size(44.dp)) {
} Icon(Icons.Filled.MoreVert, contentDescription = "Меню", tint = Color.White)
} }
onOpen?.let { open -> DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
TextButton(onClick = open) { onSave?.let { save ->
Text("Открыть", color = Color.White) 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.Intent
import android.content.ClipData import android.content.ClipData
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ContentValues
import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@@ -340,6 +345,51 @@ class QMaxRepository(
sharePayload(session = session, text = null, attachments = listOf(attachment)) 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 { suspend fun maxStatus(session: QMaxSession): MaxBridgeStatusDto {
return withFreshSession(session) { api.maxStatus(it.serverUrl, it.accessToken) } 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 { private fun commonShareMime(types: List<String>): String {
val normalized = types.map { it.takeIf(String::isNotBlank) ?: "application/octet-stream" } val normalized = types.map { it.takeIf(String::isNotBlank) ?: "application/octet-stream" }
if (normalized.isEmpty()) return "*/*" if (normalized.isEmpty()) return "*/*"
@@ -1016,6 +1016,12 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
repository.shareAttachment(session, attachment) repository.shareAttachment(session, attachment)
} }
fun saveImageToGallery(attachment: AttachmentDto, onSuccess: () -> Unit = {}) = launchLoading(false) {
val session = requireSession()
repository.saveImageToGallery(session, attachment)
onSuccess()
}
fun replyTo(message: MessageDto) { fun replyTo(message: MessageDto) {
state.value = state.value.copy(replyTarget = message, editTarget = null) state.value = state.value.copy(replyTarget = message, editTarget = null)
} }