feat(ui): save image as file (#627)

This commit is contained in:
junkfood 2024-03-06 18:54:08 +08:00 committed by GitHub
parent cad1143686
commit 37835a4964
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 238 additions and 18 deletions

View File

@ -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" />

View File

@ -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
@ -92,6 +93,9 @@ class AndroidApp : Application(), Configuration.Provider {
@Inject
lateinit var imageLoader: ImageLoader
@Inject
lateinit var imageDownloader: AndroidImageDownloader
/**
* When the application startup.
*

View File

@ -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
}
}

View File

@ -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
@ -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
@ -35,7 +52,9 @@ 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)
@ -43,14 +62,15 @@ fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) {
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)
@ -60,8 +80,6 @@ fun ReaderImageViewer(imageData: ImageData, onDismissRequest: () -> Unit = {}) {
)
}
Image(
painter = painter,
contentDescription = imageData.altText,
@ -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
})
}
}
}
}
}

View File

@ -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
@ -40,7 +42,9 @@ 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,
@ -48,6 +52,7 @@ fun ReadingPage(
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()
@ -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 }
)
}
}

View File

@ -1,5 +1,6 @@
package me.ash.reader.ui.page.home.reading
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -20,6 +21,7 @@ import me.ash.reader.domain.service.RssService
import me.ash.reader.infrastructure.di.ApplicationScope
import me.ash.reader.infrastructure.di.IODispatcher
import me.ash.reader.infrastructure.rss.RssHelper
import me.ash.reader.infrastructure.storage.AndroidImageDownloader
import java.util.Date
import javax.inject.Inject
@ -31,6 +33,7 @@ class ReadingViewModel @Inject constructor(
private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope
private val applicationScope: CoroutineScope,
private val imageDownloader: AndroidImageDownloader
) : ViewModel() {
private val _readingUiState = MutableStateFlow(ReadingUiState())
@ -195,6 +198,16 @@ class ReadingViewModel @Inject constructor(
} ?: return false
return true
}
fun downloadImage(
url: String,
onSuccess: (Uri) -> Unit = {},
onFailure: (Throwable) -> Unit = {}
) {
viewModelScope.launch {
imageDownloader.downloadImage(url).onSuccess(onSuccess).onFailure(onFailure)
}
}
}
data class ReadingUiState(

View File

@ -421,4 +421,7 @@
<string name="toggle_starred">Toggle starred</string>
<string name="export">Export</string>
<string name="pull_to_switch_article">Pull to switch article</string>
<string name="save">Save</string>
<string name="image_saved">Image saved</string>
<string name="permission_denied">Permission denied</string>
</resources>