Add image viewer gallery actions
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user