mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-04-01 00:18:30 +02:00
Merge pull request #99 from Radiokot/feature/save_images
Add Save option to the image view screen
This commit is contained in:
commit
df49c63936
@ -5,6 +5,9 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- Used for SDK < 29 -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<application
|
||||
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.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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user