Skip to content

Commit

Permalink
feat(ui): save image as file (#627)
Browse files Browse the repository at this point in the history
  • Loading branch information
JunkFood02 authored Mar 6, 2024
1 parent cad1143 commit 37835a4
Show file tree
Hide file tree
Showing 7 changed files with 238 additions and 18 deletions.
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import me.ash.reader.infrastructure.di.IODispatcher
import me.ash.reader.infrastructure.net.NetworkDataSource
import me.ash.reader.infrastructure.rss.OPMLDataSource
import me.ash.reader.infrastructure.rss.RssHelper
import me.ash.reader.infrastructure.storage.AndroidImageDownloader
import me.ash.reader.ui.ext.del
import me.ash.reader.ui.ext.getLatestApk
import me.ash.reader.ui.ext.isGitHub
Expand Down Expand Up @@ -92,6 +93,9 @@ class AndroidApp : Application(), Configuration.Provider {
@Inject
lateinit var imageLoader: ImageLoader

@Inject
lateinit var imageDownloader: AndroidImageDownloader

/**
* When the application startup.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package me.ash.reader.infrastructure.storage

import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.webkit.URLUtil
import androidx.annotation.CheckResult
import androidx.annotation.DeprecatedSinceApi
import androidx.core.content.contentValuesOf
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import me.ash.reader.R
import me.ash.reader.infrastructure.di.IODispatcher
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.FileOutputStream
import java.io.IOException
import javax.inject.Inject
import kotlin.io.path.Path
import kotlin.io.path.createFile
import kotlin.io.path.createParentDirectories

private const val TAG = "AndroidImageDownloader"

class AndroidImageDownloader @Inject constructor(
@ApplicationContext private val context: Context,
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
private val okHttpClient: OkHttpClient,
) {
@CheckResult
suspend fun downloadImage(imageUrl: String): Result<Uri> {
return withContext(ioDispatcher) {
Request.Builder().url(imageUrl).build().runCatching {
okHttpClient.newCall(this).execute().run {

val fileName = URLUtil.guessFileName(
imageUrl, header("Content-Disposition"), body.contentType()?.toString()
)

val relativePath =
Environment.DIRECTORY_PICTURES + "/" + context.getString(R.string.read_you)

val resolver = context.contentResolver

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val imageCollection =
MediaStore.Images.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY
)


val imageDetails = contentValuesOf(
MediaStore.Images.Media.DISPLAY_NAME to fileName,
MediaStore.Images.Media.RELATIVE_PATH to relativePath,
MediaStore.Images.Media.IS_PENDING to 1
)

val imageUri = resolver.insert(imageCollection, imageDetails)
?: return@withContext Result.failure(IOException("Cannot create image"))

resolver.openFileDescriptor(imageUri, "w", null).use { pfd ->
body.byteStream().use {
it.copyTo(
FileOutputStream(
pfd?.fileDescriptor ?: return@withContext Result.failure(
IOException("Null fd")
)
)
)
}
}
imageDetails.run {
clear()
put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(imageUri, this, null, null)
}
imageUri
} else {
saveImageForAndroidP(
fileName,
Environment.getExternalStoragePublicDirectory(relativePath).path
)
}
}
}
}

}

@DeprecatedSinceApi(29)
private fun Response.saveImageForAndroidP(
fileName: String,
imageDirectory: String,
): Uri {
val file = Path(imageDirectory, fileName).createParentDirectories().createFile().toFile()

body.byteStream().use {
it.copyTo(file.outputStream())
}

var contentUri: Uri = Uri.fromFile(file)

MediaScannerConnection.scanFile(context, arrayOf(file.path), null) { _, uri ->
contentUri = uri
}

return contentUri
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package me.ash.reader.ui.page.home.reading

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
Expand All @@ -9,24 +14,36 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.content.ContextCompat
import androidx.core.view.HapticFeedbackConstantsCompat
import coil.compose.rememberAsyncImagePainter

import me.ash.reader.R
import me.ash.reader.ui.component.base.RYAsyncImage
import me.ash.reader.ui.ext.showToast
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.ZoomableContentLocation
import me.saket.telephoto.zoomable.rememberZoomableState
Expand All @@ -35,22 +52,25 @@ import me.saket.telephoto.zoomable.zoomable
data class ImageData(val imageUrl: String = "", val altText: String = "")

@Composable
fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) {
fun ReaderImageViewer(
imageData: ImageData, onDownloadImage: (String) -> Unit, onDismissRequest: () -> Unit = {}
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
modifier = Modifier
.fillMaxSize()
// .background(Color.Black)
.windowInsetsPadding(WindowInsets.systemBars)
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
val view = LocalView.current
val context = LocalContext.current

val dialogWindowProvider = view.parent as? DialogWindowProvider
dialogWindowProvider?.window?.setDimAmount(1f)

val zoomableState =
rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 4f))
val zoomableState = rememberZoomableState(zoomSpec = ZoomSpec(maxZoomFactor = 4f))

val painter = rememberAsyncImagePainter(model = imageData.imageUrl)

Expand All @@ -60,8 +80,6 @@ fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) {
)
}



Image(
painter = painter,
contentDescription = imageData.altText,
Expand All @@ -73,18 +91,66 @@ fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) {
)

IconButton(
onClick = onDismissRequest,
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Gray.copy(alpha = 0.5f),
contentColor = Color.White
),
modifier = Modifier.padding(12.dp)
onClick = {
view.performHapticFeedback(HapticFeedbackConstantsCompat.KEYBOARD_TAP)
onDismissRequest()
}, colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Black.copy(alpha = 0.5f), contentColor = Color.White
), modifier = Modifier
.padding(4.dp)
.align(Alignment.TopStart)
) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(id = R.string.close)
contentDescription = stringResource(id = R.string.close),
)
}
var expanded by remember { mutableStateOf(false) }
IconButton(
onClick = { expanded = true }, colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Black.copy(alpha = 0.5f), contentColor = Color.White
), modifier = Modifier
.padding(4.dp)
.align(Alignment.TopEnd)
) {
Icon(
imageVector = Icons.Outlined.MoreHoriz,
contentDescription = stringResource(id = R.string.more),
)
}

val launcher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(),
onResult = { result ->
if (result) {
onDownloadImage(imageData.imageUrl)
} else {
context.showToast(context.getString(R.string.permission_denied))
}
})

Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp)
) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(text = { Text(text = stringResource(id = R.string.save)) },
onClick = {
val isStoragePermissionGranted = ContextCompat.checkSelfPermission(
context, Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED

if (Build.VERSION.SDK_INT > 28 || isStoragePermissionGranted) {
onDownloadImage(imageData.imageUrl)
} else {
launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
expanded = false
})
}
}

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import me.ash.reader.R
import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar
import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.motion.materialSharedAxisY
import me.ash.reader.ui.page.home.HomeViewModel
import kotlin.math.abs
Expand All @@ -40,14 +42,17 @@ import kotlin.math.abs
private const val UPWARD = 1
private const val DOWNWARD = -1

@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
@OptIn(
ExperimentalFoundationApi::class, ExperimentalMaterialApi::class
)
@Composable
fun ReadingPage(
navController: NavHostController,
homeViewModel: HomeViewModel,
readingViewModel: ReadingViewModel = hiltViewModel(),
) {
val tonalElevation = LocalReadingPageTonalElevation.current
val context = LocalContext.current
val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value
val readingUiState = readingViewModel.readingUiState.collectAsStateValue()
val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue()
Expand Down Expand Up @@ -229,6 +234,20 @@ fun ReadingPage(
}
)
if (showFullScreenImageViewer) {
ReaderImageViewer(imageData = currentImageData) { showFullScreenImageViewer = false }

ReaderImageViewer(
imageData = currentImageData,
onDownloadImage = {
readingViewModel.downloadImage(
it,
onSuccess = { context.showToast(context.getString(R.string.image_saved)) },
onFailure = {
// FIXME: crash the app for error report
th -> throw th
}
)
},
onDismissRequest = { showFullScreenImageViewer = false }
)
}
}
Loading

0 comments on commit 37835a4

Please sign in to comment.