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) }