Merge pull request #99 from Radiokot/feature/save_images

Add Save option to the image view screen
This commit is contained in:
Vitor Pamplona
2023-02-09 09:22:46 -05:00
committed by GitHub
4 changed files with 239 additions and 13 deletions

View File

@@ -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"

View File

@@ -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"
}

View File

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

View File

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