diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 92d8ba6cc..9f13f86ea 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
+
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"
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt
new file mode 100644
index 000000000..7f03f20b3
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt
index 14ac38236..1dbaf9e27 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt
@@ -2,35 +2,26 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.Arrangement
-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.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
+import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
-import nostr.postr.toNpub
+import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
@Composable
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
@@ -75,6 +66,8 @@ fun ZoomableImageView(word: String) {
CloseButton(onCancel = {
dialogOpen = false
})
+
+ SaveToGallery(url = word)
}
ZoomableAsyncImage(word)