mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-10-09 18:22:48 +02:00
Merge pull request #99 from Radiokot/feature/save_images
Add Save option to the image view screen
This commit is contained in:
@@ -5,6 +5,9 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<!-- Used for SDK < 29 -->
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
@@ -0,0 +1,147 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.actions
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import okhttp3.*
|
||||||
|
import okio.BufferedSource
|
||||||
|
import okio.IOException
|
||||||
|
import okio.sink
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
|
||||||
|
object ImageSaver {
|
||||||
|
/**
|
||||||
|
* Saves the image to the gallery.
|
||||||
|
* May require a storage permission.
|
||||||
|
*
|
||||||
|
* @see PICTURES_SUBDIRECTORY
|
||||||
|
*/
|
||||||
|
fun saveImage(
|
||||||
|
url: String,
|
||||||
|
context: Context,
|
||||||
|
onSuccess: () -> Any?,
|
||||||
|
onError: (Throwable) -> Any?,
|
||||||
|
) {
|
||||||
|
val client = OkHttpClient.Builder().build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.get()
|
||||||
|
.url(url)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
client.newCall(request).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
onError(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
try {
|
||||||
|
check(response.isSuccessful)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val contentType = response.header("Content-Type")
|
||||||
|
checkNotNull(contentType) {
|
||||||
|
"Can't find out the content type"
|
||||||
|
}
|
||||||
|
|
||||||
|
saveContentQ(
|
||||||
|
displayName = File(url).nameWithoutExtension,
|
||||||
|
contentType = contentType,
|
||||||
|
contentSource = response.body.source(),
|
||||||
|
contentResolver = context.contentResolver,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
saveContentDefault(
|
||||||
|
fileName = File(url).name,
|
||||||
|
contentSource = response.body.source(),
|
||||||
|
context = context,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSuccess()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
onError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
private fun saveContentQ(
|
||||||
|
displayName: String,
|
||||||
|
contentType: String,
|
||||||
|
contentSource: BufferedSource,
|
||||||
|
contentResolver: ContentResolver,
|
||||||
|
) {
|
||||||
|
val contentValues = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, contentType)
|
||||||
|
put(
|
||||||
|
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||||
|
Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri =
|
||||||
|
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
|
||||||
|
checkNotNull(uri) {
|
||||||
|
"Can't insert the new content"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val outputStream = contentResolver.openOutputStream(uri)
|
||||||
|
checkNotNull(outputStream) {
|
||||||
|
"Can't open the content output stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
outputStream.use {
|
||||||
|
contentSource.readAll(it.sink())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
contentResolver.delete(uri, null, null)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveContentDefault(
|
||||||
|
fileName: String,
|
||||||
|
contentSource: BufferedSource,
|
||||||
|
context: Context,
|
||||||
|
) {
|
||||||
|
val subdirectory = File(
|
||||||
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
|
||||||
|
PICTURES_SUBDIRECTORY
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!subdirectory.exists()) {
|
||||||
|
subdirectory.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val outputFile = File(subdirectory, fileName)
|
||||||
|
|
||||||
|
outputFile
|
||||||
|
.outputStream()
|
||||||
|
.use {
|
||||||
|
contentSource.readAll(it.sink())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the media scanner manually, so the image
|
||||||
|
// appears in the gallery faster.
|
||||||
|
context.sendBroadcast(
|
||||||
|
Intent(
|
||||||
|
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
|
||||||
|
outputFile.toUri()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PICTURES_SUBDIRECTORY = "Amethyst"
|
||||||
|
}
|
@@ -0,0 +1,83 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.actions
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.isGranted
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A button to save the remote image to the gallery.
|
||||||
|
* May require a storage permission.
|
||||||
|
*
|
||||||
|
* @param url URL of the image
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SaveToGallery(url: String) {
|
||||||
|
val localContext = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun saveImage() {
|
||||||
|
ImageSaver.saveImage(
|
||||||
|
context = localContext,
|
||||||
|
url = url,
|
||||||
|
onSuccess = {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
localContext,
|
||||||
|
"Image saved to the gallery",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(
|
||||||
|
localContext,
|
||||||
|
"Failed to save the image",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val writeStoragePermissionState = rememberPermissionState(
|
||||||
|
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||||
|
) { isGranted ->
|
||||||
|
if (isGranted) {
|
||||||
|
saveImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
|
||||||
|
saveImage()
|
||||||
|
} else {
|
||||||
|
writeStoragePermissionState.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = ButtonDefaults
|
||||||
|
.buttonColors(
|
||||||
|
backgroundColor = Color.Gray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(text = "Save", color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
@@ -2,35 +2,26 @@ package com.vitorpamplona.amethyst.ui.components
|
|||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Surface
|
import androidx.compose.material.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||||
import nostr.postr.toNpub
|
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
|
||||||
@@ -75,6 +66,8 @@ fun ZoomableImageView(word: String) {
|
|||||||
CloseButton(onCancel = {
|
CloseButton(onCancel = {
|
||||||
dialogOpen = false
|
dialogOpen = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
SaveToGallery(url = word)
|
||||||
}
|
}
|
||||||
|
|
||||||
ZoomableAsyncImage(word)
|
ZoomableAsyncImage(word)
|
||||||
|
Reference in New Issue
Block a user