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)