Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add image save and share actions to media viewer #857

Merged
merged 3 commits into from
Feb 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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