diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8d7b180..dbadf64 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
(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()
+ }
+ )
+ }
+ }
}
}
}
diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt
index 103757f..e2bf335 100644
--- a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt
+++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt
@@ -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 {
val normalized = types.map { it.takeIf(String::isNotBlank) ?: "application/octet-stream" }
if (normalized.isEmpty()) return "*/*"
diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt
index 2b16bf0..062838e 100644
--- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt
+++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt
@@ -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)
}