Skip to content

Commit

Permalink
Add image save and share actions to media viewer (#857)
Browse files Browse the repository at this point in the history
* Add image save button

* Show media buttons in overlay

* Show snackbar on save success, failure
  • Loading branch information
jocmp authored Feb 15, 2025
1 parent 2432e32 commit 7fd8d51
Show file tree
Hide file tree
Showing 11 changed files with 418 additions and 53 deletions.
4 changes: 2 additions & 2 deletions app/src/main/java/com/capyreader/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class MainActivity : ComponentActivity() {
enableStrictModeOnDebug()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
NotificationHelper.handleResult(intent, appPreferences = appPreferences)
NotificationHelper.openArticle(intent, appPreferences = appPreferences)

val theme = appPreferences.theme

Expand All @@ -39,7 +39,7 @@ class MainActivity : ComponentActivity() {

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
NotificationHelper.handleResult(intent, appPreferences = appPreferences)
NotificationHelper.openArticle(intent, appPreferences = appPreferences)
}

private fun startDestination(): Route {
Expand Down
22 changes: 17 additions & 5 deletions app/src/main/java/com/capyreader/app/common/ContextFileExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,23 @@ import java.io.File
fun Context.fileURI(file: File): Uri =
FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.fileprovider", file)

fun Context.externalImageCacheFile(name: String): File {
val imageCache = File(externalCacheDir, "images").apply {
if (!exists()) {
mkdir()
}
}

return File(imageCache, name).apply {
createNewFile()
}
}

fun Context.createCacheFile(name: String): File {
val file = File(externalCacheDir, name)
if (file.exists()) {
file.delete()
return File(externalCacheDir, name).apply {
if (exists()) {
delete()
}
createNewFile()
}
file.createNewFile()
return file
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ class NotificationHelper(
}
}

fun handleResult(intent: Intent, appPreferences: AppPreferences) {
fun openArticle(intent: Intent, appPreferences: AppPreferences) {
val articleID = intent.getStringExtra(ARTICLE_ID_KEY) ?: return
val feedID = intent.getStringExtra(FEED_ID_KEY) ?: return
intent.replaceExtras(Bundle())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,29 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
Expand All @@ -37,6 +47,7 @@ import com.capyreader.app.ui.components.LoadingView
import com.capyreader.app.ui.components.Swiper
import com.capyreader.app.ui.components.rememberSwiperState
import com.capyreader.app.ui.isCompact
import com.capyreader.app.ui.settings.LocalSnackbarHost
import com.capyreader.app.ui.theme.CapyTheme
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
Expand Down Expand Up @@ -64,9 +75,10 @@ fun ArticleMediaView(
onDismissRequest = onDismissRequest,
showOverlay = showOverlay,
footer = {
if (caption != null) {
CaptionOverlay(caption)
}
CaptionOverlay(
caption = caption,
imageUrl = url
)
}
) {
ZoomableAsyncImage(
Expand Down Expand Up @@ -131,64 +143,100 @@ fun MediaScaffold(
)

val isOverlayVisible = showOverlay && swiperState.progress == 0f
val snackbarHostState = remember { SnackbarHostState() }

Scaffold(
containerColor = Color.Black.copy(alpha = 1f - swiperState.progress),
modifier = Modifier
.fillMaxSize()
.fillMaxSize(),
snackbarHost = {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.statusBarsPadding()
.fillMaxSize(),
) {
SnackbarHost(snackbarHostState) { data ->
val darkColors = darkColorScheme()
Snackbar(
data,
containerColor = darkColors.inverseSurface,
contentColor = darkColors.inverseOnSurface,
)
}
}
}
) { paddingValues ->
Box(
Modifier.padding(paddingValues)
CompositionLocalProvider(
LocalSnackbarHost provides snackbarHostState,
) {
Swiper(
state = swiperState,
modifier = Modifier.fillMaxSize()
Box(
Modifier.padding(paddingValues)
) {
content()
}

Box(Modifier.align(Alignment.BottomStart)) {
AnimatedVisibility(
isOverlayVisible,
enter = fadeIn() + expandVertically(),
exit = shrinkVertically() + fadeOut(),
Swiper(
state = swiperState,
modifier = Modifier.fillMaxSize()
) {
footer()
content()
}
}

CloseButton(
onClick = { onDismissRequest() },
visible = isOverlayVisible
)
Box(Modifier.align(Alignment.BottomStart)) {
AnimatedVisibility(
isOverlayVisible,
enter = fadeIn() + expandVertically(),
exit = shrinkVertically() + fadeOut(),
) {
footer()
}
}

CloseButton(
onClick = { onDismissRequest() },
visible = isOverlayVisible
)
}
}
}
}

@Composable
private fun CaptionOverlay(text: String) {
Box(
Modifier
private fun CaptionOverlay(caption: String?, imageUrl: String) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = if (isCompact()) {
Alignment.Start
} else {
Alignment.CenterHorizontally
},
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.6f))
.background(Color.Black.copy(alpha = 0.8f))
.padding(vertical = 8.dp, horizontal = 16.dp)
) {
Box(
Modifier
.align(
if (isCompact()) {
Alignment.TopStart
} else {
Alignment.TopCenter
}
if (!caption.isNullOrBlank()) {
Box(
Modifier
.then(
if (isCompact()) {
Modifier.fillMaxWidth()
} else {
Modifier.widthIn(max = 600.dp)
}
)
) {
Text(
caption,
color = MediaColors.textColor,
modifier = Modifier
.padding(top = 8.dp)
)
.widthIn(max = 600.dp)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text,
color = Color.White.copy(0.8f),
modifier = Modifier
.padding(16.dp)
)
MediaSaveButton(imageUrl)
MediaShareButton(imageUrl)
}
}
}
Expand Down Expand Up @@ -218,7 +266,8 @@ private fun ArticleMediaViewPreview_Foldable() {
) {
Box(Modifier.align(Alignment.BottomStart)) {
CaptionOverlay(
"A description of the picture you're taking a look at"
"A description of the picture you're taking a look at",
"http://example.com/test.jpg"
)
}
}
Expand All @@ -236,7 +285,8 @@ private fun ArticleMediaViewPreview_Phone() {
) {
Box(Modifier.align(Alignment.BottomStart)) {
CaptionOverlay(
"A description"
"A description",
"http://example.com/test.jpg"
)
}
}
Expand All @@ -255,7 +305,8 @@ private fun ArticleMediaViewPreview_Tablet() {
) {
Box(Modifier.align(Alignment.BottomStart)) {
CaptionOverlay(
"A description of the picture you're taking a look at"
"A description of the picture you're taking a look at",
"http://example.com/test.jpg"
)
}
}
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/capyreader/app/ui/articles/media/Colors.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.capyreader.app.ui.articles.media

import androidx.compose.ui.graphics.Color

object MediaColors {
val textColor = Color.White.copy(0.8f)

val buttonContentColor = Color.White

val buttonOutlineColor = Color.White

val buttonContainerColor = Color.Transparent
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.capyreader.app.ui.articles.media

import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import coil.executeBlocking
import coil.imageLoader
import coil.request.ImageRequest
import com.capyreader.app.common.MD5
import com.capyreader.app.common.externalImageCacheFile
import com.capyreader.app.common.fileURI
import com.jocmp.capy.logging.CapyLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.FileOutputStream
import java.io.IOException

object ImageSaver {
suspend fun saveImage(imageUrl: String, context: Context): Result<Uri> {
return withContext(Dispatchers.IO) {
return@withContext try {
val bitmap =
createBitmap(imageUrl, context) ?: throw IOException("Failed to generate image")

val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, jpegFileName(imageUrl))
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
put(
MediaStore.MediaColumns.RELATIVE_PATH,
"${Environment.DIRECTORY_PICTURES}/Capy"
)
}

val uri = context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
) ?: throw IOException("Failed to create MediaStore entry")

context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(jpegStream(bitmap))
} ?: throw IOException("Failed to open output stream")

Result.success(uri)
} catch (e: Exception) {
CapyLog.error("save_img", error = e)
Result.failure(e)
}
}
}

suspend fun shareImage(imageUrl: String, context: Context): Result<Uri> {
return withContext(Dispatchers.IO) {
return@withContext try {
val bitmap =
createBitmap(imageUrl, context) ?: throw IOException("Failed to generate image")

val target = context.externalImageCacheFile(jpegFileName(imageUrl))

context.contentResolver.openFileDescriptor(target.toUri(), "w")?.use { descriptor ->
FileOutputStream(descriptor.fileDescriptor).use {
it.write(jpegStream(bitmap))
}
}

Result.success(context.fileURI(target))
} catch (e: Exception) {
CapyLog.error("share_img", error = e)
Result.failure(e)
}
}
}

private fun jpegStream(bitmap: Bitmap): ByteArray {
val byteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream)
return byteArrayOutputStream.toByteArray()
}

private fun jpegFileName(imageUrl: String): String {
return "${MD5.from(imageUrl)}.jpg"
}

private fun createBitmap(imageUrl: String, context: Context): Bitmap? {
val imageRequest = ImageRequest.Builder(context)
.data(imageUrl)
.build()

return context.imageLoader.executeBlocking(imageRequest).drawable?.toBitmap()
}
}
Loading

0 comments on commit 7fd8d51

Please sign in to comment.